commit bfd4013fd9738c841e0bea47f99a11f5f1fd4e43 Author: iisaev Date: Sat Oct 24 10:01:16 2026 +0200 Schema Tools: Data Editor and command palette built from mod schemas Co-Authored-By: Claude Opus 5.5 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3030c1a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,30 @@ +# Line endings: LF everywhere, on every OS, whatever core.autocrlf says. +* text=auto eol=lf + +# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binaries: never converted, never diffed as text. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.svg text +*.ogg binary +*.wav binary +*.mp3 binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.dll binary +*.so binary +*.dylib binary +*.pdb binary +*.exe binary +*.zip binary +*.pck binary +*.moc3 binary +*.res binary +*.scn binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c301ca8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Godot 4.x +.godot/ +.idea/ +sdk/ +bin/ +obj/ + +#ai +.omo/ +.opencode/ + +gen/ +obj/ +bin/ + +*.tres~ +*.tscn~ + +# GDScript LSP +.godot/ + +# Export +*.exe +*.dmg +*.apk +*.aab +*.pck +*.zip +*.log + +# OS +.DS_Store +Thumbs.db + +# Editor +*.import +*.godot.uid diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..7fe1bdf --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,32 @@ +mod_schema_tools_name = Schema Tools +mod_schema_tools_desc = Editors built from annotations — RPG Maker plugin parameters turned into data any mod ships: schemas/*.json files describe scenario actions and commands and the fields of entity kinds; the mods' schemas merge, so a mod that adds a field to someone else's kind gets an editor for it at once. The Data Editor window edits any described kind with forms, the command palette builds scenario lines with a form and inserts them into the editor, the validator checks every mod's scenarios and data against the schemas. +tools/schema/palette/title = Command palette +tools/schema/palette/hint = Pick an action or a command and fill the form: the line appears below and goes into the open scenario editor under the caret. +tools/schema/palette/no_editor = No scenario is open: open one in the scenario editor or copy the line. +tools/schema/palette/inserted = The line went into {0} +tools/schema/palette/insert = Insert +tools/schema/palette/insert_tip = Insert the line into the open scenario below the caret +tools/schema/palette/copy = Copy +tools/schema/palette/copy_tip = Copy the line to the clipboard +tools/schema/palette/section_list = Actions and commands +tools/schema/palette/search = Search: damage, item, check… +tools/schema/palette/pick = Pick an action on the left +tools/schema/palette/section_line = Scenario line +tools/schema/palette/count = Described: {0} actions, {1} commands +tools/schema/described_by = Described by the mod {0} +tools/schema/palette/arrow_tip = The label to jump to (:name) +tools/schema/palette/line_ok = ✔ The line is fine +tools/schema/palette/copied = Line copied +tools/schema/category/stats = Stats +tools/schema/category/items = Items +tools/schema/category/characters = Characters +tools/schema/category/battle = Battle +tools/schema/category/world = World +tools/schema/category/quests = Quests +tools/schema/category/dialog = Dialog +tools/schema/category/flow = Flow +tools/schema/data_editor/title = Data Editor +tools/schema/data_editor/hint = On the left is everything the mods described: stats, classes, items, characters, skills. Fields added by different mods are gathered into one form. +tools/schema/data_editor/section_kinds = Data kinds +tools/schema/data_editor/search_kind = Search a kind +tools/schema/data_editor/kinds = Described kinds: {0} diff --git a/locale/es.properties b/locale/es.properties new file mode 100644 index 0000000..6654b37 --- /dev/null +++ b/locale/es.properties @@ -0,0 +1,32 @@ +mod_schema_tools_name = Herramientas de esquemas +mod_schema_tools_desc = Editores construidos a partir de anotaciones: los parámetros de plugins de RPG Maker convertidos en datos que cualquier mod incluye. Los archivos schemas/*.json describen acciones y comandos de escenarios y los campos de los tipos de entidades; los esquemas de los mods se fusionan, así que un mod que añade un campo a un tipo ajeno obtiene su editor al instante. La ventana Editor de datos edita cualquier tipo descrito con formularios, la paleta de comandos arma líneas de escenario con un formulario y las inserta en el editor, el validador comprueba los escenarios y datos de todos los mods según los esquemas. +tools/schema/palette/title = Paleta de comandos +tools/schema/palette/hint = Elige una acción o un comando y rellena el formulario: la línea aparece abajo y se inserta en el editor de escenarios abierto bajo el cursor. +tools/schema/palette/no_editor = No hay ningún escenario abierto: abre uno en el editor de escenarios o copia la línea. +tools/schema/palette/inserted = Línea insertada en {0} +tools/schema/palette/insert = Insertar +tools/schema/palette/insert_tip = Insertar la línea en el escenario abierto bajo el cursor +tools/schema/palette/copy = Copiar +tools/schema/palette/copy_tip = Copiar la línea al portapapeles +tools/schema/palette/section_list = Acciones y comandos +tools/schema/palette/search = Buscar: daño, objeto, check… +tools/schema/palette/pick = Elige una acción a la izquierda +tools/schema/palette/section_line = Línea de escenario +tools/schema/palette/count = Descritos: {0} acciones, {1} comandos +tools/schema/described_by = Descrito por el mod {0} +tools/schema/palette/arrow_tip = La etiqueta a la que saltar (:nombre) +tools/schema/palette/line_ok = ✔ La línea está bien +tools/schema/palette/copied = Línea copiada +tools/schema/category/stats = Estadísticas +tools/schema/category/items = Objetos +tools/schema/category/characters = Personajes +tools/schema/category/battle = Combate +tools/schema/category/world = Mundo +tools/schema/category/quests = Misiones +tools/schema/category/dialog = Diálogo +tools/schema/category/flow = Flujo +tools/schema/data_editor/title = Editor de datos +tools/schema/data_editor/hint = A la izquierda está todo lo que describieron los mods: estadísticas, clases, objetos, personajes, habilidades. Los campos que añadieron distintos mods se reúnen en un formulario. +tools/schema/data_editor/section_kinds = Tipos de datos +tools/schema/data_editor/search_kind = Buscar un tipo +tools/schema/data_editor/kinds = Tipos descritos: {0} diff --git a/locale/ja.properties b/locale/ja.properties new file mode 100644 index 0000000..78ad9ca --- /dev/null +++ b/locale/ja.properties @@ -0,0 +1,32 @@ +mod_schema_tools_name = スキーマツール +mod_schema_tools_desc = 注釈から作るエディター——RPG Maker のプラグインパラメーターを、どの MOD でも同梱できるデータにしたもの:schemas/*.json がシナリオの行動とコマンド、エンティティの種類のフィールドを記述する。各 MOD のスキーマは統合されるので、他人の種類にフィールドを足した MOD はすぐにそのエディターを得る。「データエディター」ウィンドウは記述された種類をフォームで編集し、コマンドパレットはフォームでシナリオの行を組み立ててエディターに挿入し、バリデーターは全 MOD のシナリオとデータをスキーマで検査する。 +tools/schema/palette/title = コマンドパレット +tools/schema/palette/hint = 行動かコマンドを選んでフォームを埋めると、下に行が現れ、開いているシナリオエディターのカーソル下に挿入される。 +tools/schema/palette/no_editor = 開いているシナリオがない:シナリオエディターで開くか、行をコピーする。 +tools/schema/palette/inserted = {0} に挿入した +tools/schema/palette/insert = 挿入 +tools/schema/palette/insert_tip = 開いているシナリオのカーソル下に行を挿入 +tools/schema/palette/copy = コピー +tools/schema/palette/copy_tip = 行をクリップボードにコピー +tools/schema/palette/section_list = 行動とコマンド +tools/schema/palette/search = 検索:ダメージ、アイテム、check… +tools/schema/palette/pick = 左で行動を選ぶ +tools/schema/palette/section_line = シナリオの行 +tools/schema/palette/count = 記述済み:行動 {0}、コマンド {1} +tools/schema/described_by = MOD {0} による記述 +tools/schema/palette/arrow_tip = ジャンプ先のラベル(:名前) +tools/schema/palette/line_ok = ✔ 行に問題なし +tools/schema/palette/copied = 行をコピーした +tools/schema/category/stats = ステータス +tools/schema/category/items = アイテム +tools/schema/category/characters = キャラクター +tools/schema/category/battle = 戦闘 +tools/schema/category/world = ワールド +tools/schema/category/quests = クエスト +tools/schema/category/dialog = 会話 +tools/schema/category/flow = 進行 +tools/schema/data_editor/title = データエディター +tools/schema/data_editor/hint = 左には MOD が記述したすべて:ステータス、職業、アイテム、キャラクター、スキル。別々の MOD が足したフィールドが一つのフォームにまとまる。 +tools/schema/data_editor/section_kinds = データの種類 +tools/schema/data_editor/search_kind = 種類を検索 +tools/schema/data_editor/kinds = 記述された種類:{0} diff --git a/locale/ko.properties b/locale/ko.properties new file mode 100644 index 0000000..94ec6a5 --- /dev/null +++ b/locale/ko.properties @@ -0,0 +1,32 @@ +mod_schema_tools_name = 스키마 도구 +mod_schema_tools_desc = 주석으로 만드는 에디터 — RPG Maker 플러그인 매개변수를 어떤 모드든 담을 수 있는 데이터로 바꾼 것: schemas/*.json 파일이 시나리오의 행동과 명령, 엔티티 종류의 필드를 설명합니다. 모드들의 스키마가 합쳐지므로, 남의 종류에 필드를 더한 모드는 즉시 그 에디터를 얻습니다. ‘데이터 에디터’ 창은 설명된 모든 종류를 양식으로 편집하고, 명령 팔레트는 양식으로 시나리오 줄을 만들어 에디터에 넣으며, 검증기는 모든 모드의 시나리오와 데이터를 스키마로 검사합니다. +tools/schema/palette/title = 명령 팔레트 +tools/schema/palette/hint = 행동이나 명령을 고르고 양식을 채우세요. 아래에 줄이 나타나고, 열린 시나리오 에디터의 커서 아래에 들어갑니다. +tools/schema/palette/no_editor = 열린 시나리오가 없습니다: 시나리오 에디터에서 열거나 줄을 복사하세요. +tools/schema/palette/inserted = {0}에 넣었습니다 +tools/schema/palette/insert = 넣기 +tools/schema/palette/insert_tip = 열린 시나리오의 커서 아래에 줄을 넣기 +tools/schema/palette/copy = 복사 +tools/schema/palette/copy_tip = 줄을 클립보드에 복사 +tools/schema/palette/section_list = 행동과 명령 +tools/schema/palette/search = 검색: 피해, 아이템, check… +tools/schema/palette/pick = 왼쪽에서 행동을 고르세요 +tools/schema/palette/section_line = 시나리오 줄 +tools/schema/palette/count = 설명됨: 행동 {0}개, 명령 {1}개 +tools/schema/described_by = 모드 {0}의 설명 +tools/schema/palette/arrow_tip = 이동할 라벨(:이름) +tools/schema/palette/line_ok = ✔ 줄에 문제 없음 +tools/schema/palette/copied = 줄을 복사했습니다 +tools/schema/category/stats = 능력치 +tools/schema/category/items = 아이템 +tools/schema/category/characters = 캐릭터 +tools/schema/category/battle = 전투 +tools/schema/category/world = 월드 +tools/schema/category/quests = 퀘스트 +tools/schema/category/dialog = 대화 +tools/schema/category/flow = 흐름 +tools/schema/data_editor/title = 데이터 에디터 +tools/schema/data_editor/hint = 왼쪽에는 모드들이 설명한 모든 것: 능력치, 직업, 아이템, 캐릭터, 스킬. 여러 모드가 더한 필드가 한 양식에 모입니다. +tools/schema/data_editor/section_kinds = 데이터 종류 +tools/schema/data_editor/search_kind = 종류 검색 +tools/schema/data_editor/kinds = 설명된 종류: {0} diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..cd69644 --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,32 @@ +mod_schema_tools_name = Инструменты схем +mod_schema_tools_desc = Редакторы по аннотациям — идея параметров плагинов RPG Maker, ставшая данными любого мода: файлы schemas/*.json описывают действия и команды сценариев и поля видов сущностей; схемы модов сливаются, поэтому мод, добавивший поле к чужому виду, сразу получает для него редактор. Окно «Редактор данных» правит любой описанный вид формами, палитра команд собирает строки сценария формой и вставляет их в редактор, валидатор проверяет сценарии и данные всех модов по схемам. +tools/schema/palette/title = Палитра команд +tools/schema/palette/hint = Выберите действие или команду, заполните форму — строка появится ниже и вставится в открытый редактор сценариев под курсор. +tools/schema/palette/no_editor = Нет открытого сценария: откройте его в редакторе сценариев или скопируйте строку. +tools/schema/palette/inserted = Строка вставлена в {0} +tools/schema/palette/insert = Вставить +tools/schema/palette/insert_tip = Вставить строку в открытый сценарий под курсор +tools/schema/palette/copy = Копировать +tools/schema/palette/copy_tip = Скопировать строку в буфер обмена +tools/schema/palette/section_list = Действия и команды +tools/schema/palette/search = Поиск: урон, предмет, check… +tools/schema/palette/pick = Выберите действие слева +tools/schema/palette/section_line = Строка сценария +tools/schema/palette/count = Описано: {0} действий, {1} команд +tools/schema/described_by = Описание из мода {0} +tools/schema/palette/arrow_tip = Метка, на которую перейти (:имя) +tools/schema/palette/line_ok = ✔ Строка в порядке +tools/schema/palette/copied = Строка скопирована +tools/schema/category/stats = Статы +tools/schema/category/items = Предметы +tools/schema/category/characters = Персонажи +tools/schema/category/battle = Бой +tools/schema/category/world = Мир +tools/schema/category/quests = Квесты +tools/schema/category/dialog = Диалог +tools/schema/category/flow = Ход сценария +tools/schema/data_editor/title = Редактор данных +tools/schema/data_editor/hint = Слева — всё, что описали моды: статы, классы, предметы, персонажи, навыки. Поля, которые добавили разные моды, собраны в одну форму. +tools/schema/data_editor/section_kinds = Виды данных +tools/schema/data_editor/search_kind = Поиск вида +tools/schema/data_editor/kinds = Видов с описанием: {0} diff --git a/locale/vi.properties b/locale/vi.properties new file mode 100644 index 0000000..722edd7 --- /dev/null +++ b/locale/vi.properties @@ -0,0 +1,32 @@ +mod_schema_tools_name = Công cụ lược đồ +mod_schema_tools_desc = Trình sửa dựng từ chú thích — tham số plugin của RPG Maker biến thành dữ liệu mà mọi mod đều kèm được: các tệp schemas/*.json mô tả hành động, lệnh kịch bản và trường của các loại thực thể; lược đồ của các mod được gộp, nên mod thêm trường cho loại của người khác có ngay trình sửa cho nó. Cửa sổ Trình sửa dữ liệu sửa mọi loại đã mô tả bằng biểu mẫu, bảng lệnh dựng dòng kịch bản bằng biểu mẫu và chèn vào trình sửa, trình kiểm tra soát kịch bản và dữ liệu của mọi mod theo lược đồ. +tools/schema/palette/title = Bảng lệnh +tools/schema/palette/hint = Chọn một hành động hoặc lệnh và điền biểu mẫu: dòng lệnh hiện bên dưới và được chèn vào trình sửa kịch bản đang mở, dưới con trỏ. +tools/schema/palette/no_editor = Chưa mở kịch bản nào: hãy mở trong trình sửa kịch bản hoặc sao chép dòng. +tools/schema/palette/inserted = Đã chèn vào {0} +tools/schema/palette/insert = Chèn +tools/schema/palette/insert_tip = Chèn dòng vào kịch bản đang mở, dưới con trỏ +tools/schema/palette/copy = Sao chép +tools/schema/palette/copy_tip = Sao chép dòng vào bộ nhớ tạm +tools/schema/palette/section_list = Hành động và lệnh +tools/schema/palette/search = Tìm: sát thương, vật phẩm, check… +tools/schema/palette/pick = Chọn một hành động bên trái +tools/schema/palette/section_line = Dòng kịch bản +tools/schema/palette/count = Đã mô tả: {0} hành động, {1} lệnh +tools/schema/described_by = Mô tả từ mod {0} +tools/schema/palette/arrow_tip = Nhãn để nhảy tới (:tên) +tools/schema/palette/line_ok = ✔ Dòng không có lỗi +tools/schema/palette/copied = Đã sao chép dòng +tools/schema/category/stats = Chỉ số +tools/schema/category/items = Vật phẩm +tools/schema/category/characters = Nhân vật +tools/schema/category/battle = Chiến đấu +tools/schema/category/world = Thế giới +tools/schema/category/quests = Nhiệm vụ +tools/schema/category/dialog = Hội thoại +tools/schema/category/flow = Luồng +tools/schema/data_editor/title = Trình sửa dữ liệu +tools/schema/data_editor/hint = Bên trái là mọi thứ các mod đã mô tả: chỉ số, lớp, vật phẩm, nhân vật, kỹ năng. Các trường do nhiều mod thêm được gom vào một biểu mẫu. +tools/schema/data_editor/section_kinds = Loại dữ liệu +tools/schema/data_editor/search_kind = Tìm loại +tools/schema/data_editor/kinds = Loại đã mô tả: {0} diff --git a/locale/zh.properties b/locale/zh.properties new file mode 100644 index 0000000..2d9c8e8 --- /dev/null +++ b/locale/zh.properties @@ -0,0 +1,32 @@ +mod_schema_tools_name = 模式工具 +mod_schema_tools_desc = 基于注解的编辑器——把 RPG Maker 的插件参数变成任何模组都能附带的数据:schemas/*.json 文件描述剧本的动作与命令以及实体类型的字段;各模组的模式会合并,所以给别人的类型加字段的模组立刻就有对应的编辑器。“数据编辑器”窗口用表单编辑任何已描述的类型,命令面板用表单拼出剧本行并插入编辑器,验证器按模式检查所有模组的剧本和数据。 +tools/schema/palette/title = 命令面板 +tools/schema/palette/hint = 选择一个动作或命令并填写表单:下方会出现该行,并插入到已打开的剧本编辑器的光标下方。 +tools/schema/palette/no_editor = 没有打开的剧本:请在剧本编辑器中打开一个,或复制该行。 +tools/schema/palette/inserted = 已插入到 {0} +tools/schema/palette/insert = 插入 +tools/schema/palette/insert_tip = 把这一行插入到已打开剧本的光标下方 +tools/schema/palette/copy = 复制 +tools/schema/palette/copy_tip = 把这一行复制到剪贴板 +tools/schema/palette/section_list = 动作与命令 +tools/schema/palette/search = 搜索:伤害、物品、check… +tools/schema/palette/pick = 在左侧选择一个动作 +tools/schema/palette/section_line = 剧本行 +tools/schema/palette/count = 已描述:{0} 个动作,{1} 个命令 +tools/schema/described_by = 由模组 {0} 描述 +tools/schema/palette/arrow_tip = 要跳转到的标签(:名称) +tools/schema/palette/line_ok = ✔ 该行没有问题 +tools/schema/palette/copied = 已复制该行 +tools/schema/category/stats = 属性 +tools/schema/category/items = 物品 +tools/schema/category/characters = 角色 +tools/schema/category/battle = 战斗 +tools/schema/category/world = 世界 +tools/schema/category/quests = 任务 +tools/schema/category/dialog = 对话 +tools/schema/category/flow = 流程 +tools/schema/data_editor/title = 数据编辑器 +tools/schema/data_editor/hint = 左侧是各模组描述的全部内容:属性、职业、物品、角色、技能。不同模组添加的字段汇集在同一个表单中。 +tools/schema/data_editor/section_kinds = 数据类型 +tools/schema/data_editor/search_kind = 搜索类型 +tools/schema/data_editor/kinds = 已描述的类型:{0} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..bb81ccf --- /dev/null +++ b/manifest.json @@ -0,0 +1,28 @@ +{ + "id": "schema_tools", + "name": "Schema Tools", + "version": "1.0.0", + "author": "ct", + "description": "The authoring tools over the schemas of schema_base: the Data Editor window edits any described entity kind with forms built from the merged fields of all mods (entity pickers, formula fields, lists of traits, stat maps, texts in every language), and the command palette builds scenario lines from action and command schemas, checks them and inserts them into the scenario editor. The schemas themselves live with the mods that own the actions and kinds.", + "category": "tools, editor", + "sources": [ + "src/**/*.cs" + ], + "test_sources": [ + "tests/*.cs" + ], + "depends": [ + "editor_tools>=1", + "dialog_base>=1", + "ui_base>=1", + "schema_base>=1" + ], + "test_suites": [ + { + "id": "edit", + "activate": [ + "--itest=schema_tools_edit" + ] + } + ] +} diff --git a/mod.jpg b/mod.jpg new file mode 100644 index 0000000..a49b1e2 Binary files /dev/null and b/mod.jpg differ diff --git a/src/DataEditor/DataEditorWindow.cs b/src/DataEditor/DataEditorWindow.cs new file mode 100644 index 0000000..4354860 --- /dev/null +++ b/src/DataEditor/DataEditorWindow.cs @@ -0,0 +1,501 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Mods.Schemas; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text.Json; + using System.Text.Json.Nodes; + using Cthangover.Core.Mods; + using Cthangover.Core.Mods.Resolvers; + using Cthangover.Core.UI.Theming; + using Cthangover.Mods.Interface.Theming; + using Cthangover.Core.Utils; + using Cthangover.Mods.Interface.Tool; + using Cthangover.Tools.Common; + using Cthangover.Tools.EntityEditor; + using Cthangover.Tools.Services; + using Godot; + + /// + /// The data editor (RPG Maker's F9 "Database", built from schemas instead of fixed tabs): every entity kind some mod described + /// in a schema — stats, archetypes, items, characters, battle actions, shops, equipment + /// slots — on the left, the entities of the chosen kind in one writable mod in the + /// middle, and a form on the right built from the merged fields of all mods' schemas. + /// + /// That merge is the point: the items mod describes price and picture, the equipment + /// mod adds "Equip" and "Traits", the stats add "Archetype" and "Stats" to characters, + /// and one form shows them all; a mod that adds a field to any kind gets an editor for + /// it by shipping a schema file, without touching this window or the kind's owner. + /// Properties no schema describes stay editable as JSON under "other fields" and are + /// written back as they were. + /// + /// Loading and saving reuse the data editors' : files are kept + /// as JSON trees, only changed files are written, texts go into the mod's locale files, + /// and Save drops the game's caches so the running game sees the change at once. + /// + public partial class DataEditorWindow : ToolWindow + { + private readonly List mods = new(); + private readonly List editors = new(); + private readonly List localeEditors = new(); + private OptionButton modPicker; + private FilteredList kindList; + private FilteredList entityList; + private VBoxContainer form; + private Label kindLabel; + private Label fileLabel; + private LineEdit idEdit; + private CodeEdit otherEdit; + private Label emptyHint; + private KindSchema kind; + private EntityDocument document; + private EntityRecord selected; + private List languages = new(); + private string modId; + private bool suppress; + private bool editing; + + /// Builds the window over the current schema catalog. + public DataEditorWindow() : base("tools/schema/data_editor/title") + { + CthangoverTheme.Apply(this); + BuildUi(); + FillMods(); + FillKinds(); + SetHint(ToolUi.Tr("tools/schema/data_editor/hint")); + } + + /// The kind shown, or null. + public string KindId => kind?.Kind; + + /// The edited mod. + public string ModId => modId; + + /// The selected entity's id, or null. + public string SelectedId => selected?.Id; + + /// The edited entities of the kind in the mod. + public EntityDocument Document => document; + + /// Shows (asks about unsaved changes first). + public void OpenKind(string kindName) + { + RunDiscardingChanges(() => DoOpen(kindName, modId), () => kindList.Select(kind?.Kind)); + } + + /// Shows the kind in another mod (asks about unsaved changes first). + public void OpenMod(string mod) + { + RunDiscardingChanges(() => DoOpen(kind?.Kind, mod), () => modPicker.Select(mods.IndexOf(modId))); + } + + /// Selects an entity by id (null: none). + public void Select(string id) + { + selected = id == null ? null : document?.Find(id); + entityList.Select(selected?.Id); + ShowSelected(); + } + + /// The editor of a field by key (tests drive the form through it). + public ISchemaFieldEditor Editor(string key) => editors.FirstOrDefault(e => string.Equals(e.Field.Key, key, StringComparison.OrdinalIgnoreCase)); + + /// Copies the form into the selected entity (what every edit does). + public void ApplyForm() => WriteForm(); + + // ── layout ───────────────────────────────────────────────── + + private void BuildUi() + { + var outer = CreateFillContainer(); + AddChild(outer); + var toolbar = CreateToolbar(); + modPicker = new OptionButton { TooltipText = ToolUi.Tr("tools/entity_editor/mod_tip") }; + modPicker.ItemSelected += OnModPicked; + toolbar.AddChild(modPicker); + toolbar.AddChild(ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/common/save_tip")); + toolbar.AddChild(ToolUi.Divider()); + toolbar.AddChild(ToolUi.Button("tools/entity_editor/new", AddNew, ButtonKind.Secondary, "tools/entity_editor/new_tip")); + toolbar.AddChild(ToolUi.Button("tools/entity_editor/duplicate", DuplicateSelected, ButtonKind.Secondary, "tools/entity_editor/duplicate_tip")); + toolbar.AddChild(ToolUi.Button("tools/entity_editor/delete", DeleteSelected, ButtonKind.Secondary, "tools/entity_editor/delete_tip")); + toolbar.AddChild(ToolUi.Divider()); + toolbar.AddChild(ToolUi.Button("tools/entity_editor/undo", Undo, ButtonKind.Secondary, "tools/entity_editor/undo_tip")); + toolbar.AddChild(ToolUi.Button("tools/entity_editor/redo", Redo, ButtonKind.Secondary, "tools/entity_editor/redo_tip")); + outer.AddChild(toolbar); + + var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + outer.AddChild(main); + var kinds = new VBoxContainer { SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + kinds.AddChild(ToolUi.Section("tools/schema/data_editor/section_kinds")); + kindList = new FilteredList(300f, "tools/schema/data_editor/search_kind") { SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + kindList.Chosen += OpenKind; + kinds.AddChild(kindList); + main.AddChild(CreateSidebar(kinds, 230)); + + var right = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + main.AddChild(right); + var entities = new VBoxContainer { SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + entities.AddChild(ToolUi.Section("tools/entity_editor/section_list")); + entityList = new FilteredList(300f, "tools/entity_editor/search") { SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + entityList.Chosen += OnEntityChosen; + entities.AddChild(entityList); + right.AddChild(CreateSidebar(entities, 280)); + + var scroll = new ScrollContainer { HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + right.AddChild(scroll); + var column = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + column.AddThemeConstantOverride("separation", UiTokens.Space2); + scroll.AddChild(column); + kindLabel = ToolUi.Hint(string.Empty); + column.AddChild(kindLabel); + emptyHint = ToolUi.Hint(ToolUi.Tr("tools/entity_editor/no_entity")); + column.AddChild(emptyHint); + form = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, Name = "Form" }; + form.AddThemeConstantOverride("separation", UiTokens.Space2); + column.AddChild(form); + } + + private void FillMods() + { + mods.Clear(); + mods.AddRange(ModFileService.ListWritableMods()); + modPicker.Clear(); + foreach (var mod in mods) + modPicker.AddItem(mod); + if (mods.Count == 0) + SetStatus(ToolUi.Tr("tools/common/no_writable_mods"), ToolStatusKind.Error); + else + modId = mods[0]; + modPicker.Select(mods.IndexOf(modId)); + } + + private void FillKinds() + { + var counts = GameEntityIndex.Kinds(); + var kinds = SchemaCatalog.Current.Kinds.Values.Where(k => k.Fields.Count > 0) + .OrderBy(k => k.Order).ThenBy(k => k.Kind, StringComparer.Ordinal).ToList(); + kindList.SetItems(kinds.Select(k => (k.Kind, $"{KindName(k)} · {(counts.TryGetValue(k.Kind, out var n) ? n : 0)}", (Texture2D)null))); + SetStatus(string.Format(ToolUi.Tr("tools/schema/data_editor/kinds"), kinds.Count)); + } + + private static string KindName(KindSchema k) => string.IsNullOrEmpty(k.Name) ? k.Kind : ToolUi.Tr(k.Name); + + // ── kind and mod ─────────────────────────────────────────── + + private void DoOpen(string kindName, string mod) + { + if (kindName == null || mod == null || !SchemaCatalog.Current.Kinds.TryGetValue(kindName, out var schema)) + return; + kind = schema; + modId = mod; + modPicker.Select(mods.IndexOf(mod)); + kindList.Select(kind.Kind); + kindLabel.Text = string.IsNullOrEmpty(kind.Description) ? KindName(kind) : $"{KindName(kind)} — {ToolUi.Tr(kind.Description)}"; + var sources = ModFileService.ListFiles(mod, string.Empty, ".json").Select(path => (path, ModFileService.ReadText(mod, path))); + document = EntityDocument.Load(kind.Kind, $"{Folder()}/{mod}.json", sources, out var skipped); + if (skipped.Count > 0) + SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/broken_files"), string.Join(", ", skipped)), ToolStatusKind.Error); + languages = LocaleFileService.Languages(mod); + BuildForm(); + MarkClean(); + editing = false; + RefreshList(); + selected = document.Records.FirstOrDefault(); + entityList.Select(selected?.Id); + ShowSelected(); + SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/opened"), document.Records.Count, mod)); + } + + private string Folder() => string.IsNullOrWhiteSpace(kind?.Folder) ? kind?.Kind?.Replace('/', '_') : kind.Folder; + + private void OnModPicked(long index) + { + if (index >= 0 && index < mods.Count && mods[(int)index] != modId) + OpenMod(mods[(int)index]); + } + + private void OnEntityChosen(string id) => Select(id); + + private void RefreshList() + { + var name = editors.OfType().FirstOrDefault(); + entityList.SetItems(document.Records.Select(r => (r.Id, RowText(r, name), (Texture2D)null))); + entityList.Select(selected?.Id); + } + + private string RowText(EntityRecord record, LocaleFieldEditor nameField) + { + var key = nameField == null ? null : record.Data[nameField.Field.Key]?.ToString(); + var language = languages.FirstOrDefault() ?? "ru"; + var text = key == null ? null : document.GetText(language, key) ?? LocaleFileService.Read(modId, language, key); + return string.IsNullOrWhiteSpace(text) ? record.Id : $"{text} · {record.Id}"; + } + + // ── form ─────────────────────────────────────────────────── + + private void BuildForm() + { + foreach (var child in form.GetChildren()) + { + form.RemoveChild(child); + child.QueueFree(); + } + editors.Clear(); + localeEditors.Clear(); + fileLabel = ToolUi.Hint(string.Empty); + form.AddChild(fileLabel); + idEdit = ToolUi.Field(form, "tools/entity_editor/id", ToolUi.Text(string.Empty, OnTyped), "tools/entity_editor/id_tip"); + idEdit.Name = "Id"; + idEdit.AddThemeFontOverride("font", ToolUi.Mono()); + foreach (var field in kind.Fields.Where(f => !string.Equals(f.Key, "Id", StringComparison.OrdinalIgnoreCase))) + editors.Add(CreateEditor(field)); + foreach (var locale in localeEditors) + locale.Open(document, modId, languages); + + form.AddChild(ToolUi.Section("tools/entity_editor/section_other")); + form.AddChild(ToolUi.Hint(ToolUi.Tr("tools/entity_editor/other_hint"))); + otherEdit = ToolUi.Code(); + otherEdit.Name = "Other"; + otherEdit.CustomMinimumSize = new Vector2(0, 140); + otherEdit.TextChanged += OnOtherChanged; + form.AddChild(otherEdit); + } + + private ISchemaFieldEditor CreateEditor(FieldSchema field) + { + var editor = SchemaFieldEditors.Create(form, field, WriteForm); + if (editor is LocaleFieldEditor locale) + localeEditors.Add(locale); + return editor; + } + + private void ShowSelected() + { + emptyHint.Visible = selected == null; + form.Visible = selected != null; + if (selected == null) + return; + suppress = true; + fileLabel.Text = string.Format(ToolUi.Tr("tools/entity_editor/file"), modId, selected.File); + idEdit.Text = selected.Id; + foreach (var editor in editors) + editor.Load(Find(selected.Data, editor.Field.Key)); + otherEdit.Text = OtherJson(selected.Data); + suppress = false; + editing = false; + } + + private static JsonNode Find(JsonObject data, string key) + => data.FirstOrDefault(p => string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase)).Value; + + private void OnTyped(string text) => WriteForm(); + + private void WriteForm() + { + if (suppress || selected == null) + return; + if (!editing) + { + document.BeginChange(); + editing = true; + } + var data = selected.Data; + data["Id"] = idEdit.Text.Trim(); + foreach (var editor in editors) + { + var existing = data.FirstOrDefault(p => string.Equals(p.Key, editor.Field.Key, StringComparison.OrdinalIgnoreCase)).Key ?? editor.Field.Key; + var value = editor.Save(); + if (value == null) + data.Remove(existing); + else + data[existing] = value; + } + SetDirty(); + RefreshList(); + } + + private bool IsFormKey(string key) + => key == "Id" || kind.Fields.Any(f => string.Equals(f.Key, key, StringComparison.OrdinalIgnoreCase)); + + private string OtherJson(JsonObject data) + { + var other = new JsonObject(); + foreach (var pair in data.Where(p => !IsFormKey(p.Key))) + other[pair.Key] = pair.Value?.DeepClone(); + return EntityDocument.Serialize(other).TrimEnd(); + } + + private void OnOtherChanged() + { + if (suppress || selected == null) + return; + JsonObject other; + try + { + other = JsonNode.Parse(otherEdit.Text) as JsonObject; + } + catch (JsonException ex) + { + SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/other_invalid"), ex.Message), ToolStatusKind.Error); + return; + } + if (other == null) + return; + if (!editing) + { + document.BeginChange(); + editing = true; + } + var data = selected.Data; + foreach (var key in data.Select(p => p.Key).Where(k => !IsFormKey(k)).ToList()) + data.Remove(key); + foreach (var pair in other.Where(p => !IsFormKey(p.Key)).ToList()) + data[pair.Key] = pair.Value?.DeepClone(); + SetDirty(); + } + + // ── commands ─────────────────────────────────────────────── + + /// Adds a new entity of the kind with the schema's defaults (Ctrl+N). + public void AddNew() + { + if (document == null || kind == null) + return; + document.BeginChange(); + var pattern = string.IsNullOrWhiteSpace(kind.NewId) ? "{mod}/new_" + kind.Kind.Replace('/', '_') : kind.NewId; + var id = document.FreeId(pattern.Replace("{mod}", modId)); + var entity = new JsonObject { ["Id"] = id }; + foreach (var field in kind.Fields.Where(f => !string.IsNullOrEmpty(f.Default))) + entity[field.Key] = SchemaJson.ToNode(field, field.Default); + foreach (var field in kind.Fields.Where(f => f.Type == "locale" && !string.IsNullOrEmpty(f.KeyPattern))) + { + var key = field.KeyPattern.Replace("{mod}", modId).Replace("{id}", id).Replace("{key}", field.Key.ToLowerInvariant()); + entity[field.Key] = key; + foreach (var language in languages) + document.SetText(language, key, field.Key == "Name" ? id.Substring(id.LastIndexOf('/') + 1) : string.Empty); + } + var record = document.Add(entity); + SetDirty(); + selected = record; + RefreshList(); + Select(record.Id); + SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/added"), id, record.File)); + } + + private void DuplicateSelected() + { + if (selected == null) + return; + document.BeginChange(); + var record = document.Duplicate(selected, document.FreeId(selected.Id)); + SetDirty(); + selected = record; + RefreshList(); + Select(record.Id); + } + + private void DeleteSelected() + { + if (selected == null) + return; + ShowConfirm("tools/entity_editor/delete_title", "tools/entity_editor/delete_text", ConfirmDelete); + } + + private void ConfirmDelete() + { + if (selected == null) + return; + document.BeginChange(); + document.Remove(selected); + selected = null; + SetDirty(); + RefreshList(); + ShowSelected(); + } + + private void Undo() => StepHistory(document != null && document.Undo()); + + private void Redo() => StepHistory(document != null && document.Redo()); + + private void StepHistory(bool stepped) + { + if (!stepped) + return; + var id = selected?.Id; + SetDirty(); + RefreshList(); + Select(document.Find(id)?.Id ?? document.Records.FirstOrDefault()?.Id); + } + + /// + protected override void OnSaveShortcut() => Save(); + + private void OnSavePressed() => Save(); + + /// + /// Writes the changed data and locale files and makes the game re-read them (entity + /// and file caches, current language, the tools' own entity index). + /// + public bool Save() + { + if (document == null) + return false; + var problems = document.Validate(); + if (problems.Count > 0) + { + SetStatus(string.Format(ToolUi.Tr(problems[0].Key), problems[0].Argument), ToolStatusKind.Error); + return false; + } + var changedFiles = document.ChangedFiles(); + var changedTexts = document.ChangedTexts(); + foreach (var (path, text) in changedFiles) + { + if (!ModFileService.WriteText(modId, path, text)) + { + SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error); + return false; + } + } + foreach (var (language, key, value) in changedTexts) + LocaleFileService.Write(modId, language, key, value); + document.MarkSaved(); + MarkClean(); + editing = false; + ModManager.Instance.GetResolver()?.ClearCache(); + ModManager.Instance.GetResolver()?.ClearCache(); + GameEntityIndex.Refresh(); + if (changedTexts.Count > 0) + LocaleFileService.ReloadGame(); + GameLogger.Log("TOOLS", $"data editor: saved {changedFiles.Count} {kind.Kind} file(s), {changedTexts.Count} text(s) of '{modId}'"); + SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/saved"), changedFiles.Count, changedTexts.Count, modId), ToolStatusKind.Ok); + FillKinds(); + kindList.Select(kind.Kind); + return true; + } + + /// + protected override bool HandleKey(InputEventKey key) + { + if (!key.CtrlPressed) + return false; + switch (key.Keycode) + { + case Key.Z when key.ShiftPressed: + case Key.Y: + Redo(); + return true; + case Key.Z: + Undo(); + return true; + case Key.N: + AddNew(); + return true; + case Key.D: + DuplicateSelected(); + return true; + } + return false; + } + } +} diff --git a/src/Palette/CommandPaletteWindow.cs b/src/Palette/CommandPaletteWindow.cs new file mode 100644 index 0000000..73e59ef --- /dev/null +++ b/src/Palette/CommandPaletteWindow.cs @@ -0,0 +1,223 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Mods.Schemas; + using System; + using System.Collections.Generic; + using System.Linq; + using Cthangover.Core.UI.Theming; + using Cthangover.Core.UI.Tool.ScenarioEditor; + using Cthangover.Mods.Interface.Theming; + using Cthangover.Mods.Interface.Tool; + using Cthangover.Tools.Common; + using Godot; + + /// + /// The command palette: every scenario action and command some mod described, searchable + /// and grouped, with a form built from its schema. The author fills the form (pickers + /// for entities, check boxes, formula fields), sees the line it makes and what the + /// validator would say about it, and inserts the line into the open scenario editor + /// below the caret — or copies it. Nothing about an action is hard-coded here: a new + /// mod with a schema file appears in the palette as soon as it is loaded. + /// + /// The text stays the source of truth: the palette writes exactly the DSL line an author + /// would have typed, so a professional keeps typing and a beginner never has to. + /// + public partial class CommandPaletteWindow : ToolWindow + { + private const string ActionPrefix = "action:"; + private const string CommandPrefix = "command:"; + + private readonly List controls = new(); + private FilteredList list; + private VBoxContainer form; + private Label titleLabel; + private Label helpLabel; + private LineEdit arrowEdit; + private LineEdit preview; + private Label issuesLabel; + private ActionSchema action; + private CommandSchema command; + + /// Builds the window over the current schema catalog. + public CommandPaletteWindow() : base("tools/schema/palette/title") + { + CthangoverTheme.Apply(this); + BuildUi(); + Fill(); + SetHint(ToolUi.Tr("tools/schema/palette/hint")); + } + + /// The line the form makes now (empty before anything is chosen). + public string Line => preview?.Text ?? string.Empty; + + /// Chooses an entry: action:<id> or command:<word>. + public void Choose(string entryId) + { + list.Select(entryId); + OnChosen(entryId); + } + + /// Sets a form value by parameter key (tests drive the form like a user). + public void SetValue(string key, string value) + { + var control = controls.FirstOrDefault(c => string.Equals(c.Field.Key, key, StringComparison.OrdinalIgnoreCase)); + if (control == null) + return; + control.Value = value; + Rebuild(); + } + + /// Inserts below the caret of the open scenario editor; false when none is open. + public bool InsertIntoEditor() + { + var tabs = GetTree()?.Root.FindChildren("*", "", true, false).OfType().FirstOrDefault(t => t.HasActiveFile); + if (tabs == null || string.IsNullOrEmpty(Line)) + { + SetStatus(ToolUi.Tr("tools/schema/palette/no_editor"), ToolStatusKind.Error); + return false; + } + var code = tabs.CodeEditor; + var at = Math.Min(code.GetCaretLine() + 1, code.GetLineCount()); + code.InsertLineAt(at, Line); + code.SetCaretLine(at); + SetStatus(string.Format(ToolUi.Tr("tools/schema/palette/inserted"), tabs.CurrentKey), ToolStatusKind.Ok); + return true; + } + + private void BuildUi() + { + var outer = CreateFillContainer(); + AddChild(outer); + var toolbar = CreateToolbar(); + toolbar.AddChild(ToolUi.Button("tools/schema/palette/insert", OnInsertPressed, ButtonKind.Primary, "tools/schema/palette/insert_tip")); + toolbar.AddChild(ToolUi.Button("tools/schema/palette/copy", OnCopyPressed, ButtonKind.Secondary, "tools/schema/palette/copy_tip")); + outer.AddChild(toolbar); + + var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + outer.AddChild(main); + var left = new VBoxContainer { SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + left.AddChild(ToolUi.Section("tools/schema/palette/section_list")); + list = new FilteredList(360f, "tools/schema/palette/search") { SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + list.Chosen += OnChosen; + left.AddChild(list); + main.AddChild(CreateSidebar(left, 340)); + + var scroll = new ScrollContainer { HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + main.AddChild(scroll); + var column = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + column.AddThemeConstantOverride("separation", UiTokens.Space2); + scroll.AddChild(column); + titleLabel = UiKit.Label(ToolUi.Tr("tools/schema/palette/pick"), UiTokens.FontBodyLarge, UiTokens.InkBright); + column.AddChild(titleLabel); + helpLabel = ToolUi.Hint(string.Empty); + column.AddChild(helpLabel); + form = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + form.AddThemeConstantOverride("separation", UiTokens.Space2); + column.AddChild(form); + column.AddChild(ToolUi.Section("tools/schema/palette/section_line")); + preview = new LineEdit { Editable = false, Name = "Preview", SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + preview.AddThemeFontOverride("font", ToolUi.Mono()); + column.AddChild(preview); + issuesLabel = ToolUi.Hint(string.Empty); + issuesLabel.Name = "Issues"; + column.AddChild(issuesLabel); + } + + private void Fill() + { + var catalog = SchemaCatalog.Current; + var items = new List<(string Id, string Text, Texture2D Icon)>(); + foreach (var a in catalog.Actions.Values.OrderBy(a => a.Category).ThenBy(a => a.Id, StringComparer.Ordinal)) + items.Add((ActionPrefix + a.Id, Entry(a.Category, a.Name, "action " + a.Id), null)); + foreach (var c in catalog.Commands.Values.OrderBy(c => c.Category).ThenBy(c => c.Id, StringComparer.Ordinal)) + items.Add((CommandPrefix + c.Id, Entry(c.Category, c.Name, c.Id), null)); + list.SetItems(items); + SetStatus(string.Format(ToolUi.Tr("tools/schema/palette/count"), catalog.Actions.Count, catalog.Commands.Count)); + } + + private static string Entry(string category, string nameKey, string syntax) + { + var name = string.IsNullOrEmpty(nameKey) ? syntax : ToolUi.Tr(nameKey); + var group = string.IsNullOrEmpty(category) ? string.Empty : $"[{Category(category)}] "; + return $"{group}{name} · {syntax}"; + } + + private static string Category(string category) + { + var key = "tools/schema/category/" + category; + var text = ToolUi.Tr(key); + return text == key ? category : text; + } + + private void OnChosen(string entryId) + { + action = null; + command = null; + var catalog = SchemaCatalog.Current; + if (entryId?.StartsWith(ActionPrefix, StringComparison.Ordinal) == true) + catalog.Actions.TryGetValue(entryId.Substring(ActionPrefix.Length), out action); + else if (entryId?.StartsWith(CommandPrefix, StringComparison.Ordinal) == true) + catalog.Commands.TryGetValue(entryId.Substring(CommandPrefix.Length), out command); + BuildForm(); + } + + private void BuildForm() + { + foreach (var child in form.GetChildren()) + { + form.RemoveChild(child); + child.QueueFree(); + } + controls.Clear(); + arrowEdit = null; + var nameKey = action?.Name ?? command?.Name; + titleLabel.Text = nameKey == null ? ToolUi.Tr("tools/schema/palette/pick") : ToolUi.Tr(nameKey); + var help = action?.Description ?? command?.Description; + var source = action?.SourceMod ?? command?.SourceMod; + helpLabel.Text = (help == null ? string.Empty : ToolUi.Tr(help) + "\n") + (source == null ? string.Empty : string.Format(ToolUi.Tr("tools/schema/described_by"), source)); + var fields = action != null ? action.Params : command != null ? (command.Positional ?? new()).Concat(command.Params ?? new()).ToList() : new List(); + foreach (var field in fields) + controls.Add(new SchemaFieldControl(form, field, Rebuild)); + if (command?.Arrow != null) + arrowEdit = ToolUi.Field(form, command.Arrow, ToolUi.Text(":label", OnArrowTyped), "tools/schema/palette/arrow_tip"); + Rebuild(); + } + + private void OnArrowTyped(string text) => Rebuild(); + + private void Rebuild() + { + var values = controls.ToDictionary(c => c.Field.Key, c => c.Value, StringComparer.OrdinalIgnoreCase); + List issues; + if (action != null) + { + preview.Text = SchemaLine.Action(action, values); + issues = SchemaChecker.CheckNamed(action.Id, action.Params, values.Where(p => p.Value.Length > 0).ToDictionary(p => p.Key, p => p.Value), GameEntityIndex.Exists); + } + else if (command != null) + { + preview.Text = SchemaLine.Command(command, values, arrowEdit?.Text); + var positional = (command.Positional ?? new()).Select(f => values.GetValueOrDefault(f.Key)).ToList(); + issues = SchemaChecker.CheckPositional(command.Id, command.Positional, positional, GameEntityIndex.Exists); + issues.AddRange(SchemaChecker.CheckNamed(command.Id, command.Params, (command.Params ?? new()).Where(f => values.GetValueOrDefault(f.Key)?.Length > 0).ToDictionary(f => f.Key, f => values[f.Key]), GameEntityIndex.Exists)); + } + else + { + preview.Text = string.Empty; + issues = new List(); + } + issuesLabel.Text = issues.Count == 0 ? (preview.Text.Length > 0 ? ToolUi.Tr("tools/schema/palette/line_ok") : string.Empty) : string.Join("\n", issues.Select(i => (i.IsError ? "✖ " : "⚠ ") + i.Message)); + issuesLabel.AddThemeColorOverride("font_color", issues.Any(i => i.IsError) ? UiTokens.DangerText : issues.Count > 0 ? UiTokens.Warning : UiTokens.SuccessText); + } + + private void OnInsertPressed() => InsertIntoEditor(); + + private void OnCopyPressed() + { + if (string.IsNullOrEmpty(Line)) + return; + DisplayServer.ClipboardSet(Line); + SetStatus(ToolUi.Tr("tools/schema/palette/copied"), ToolStatusKind.Ok); + } + } +} diff --git a/src/Tools/DataEditorToolBoxButton.cs b/src/Tools/DataEditorToolBoxButton.cs new file mode 100644 index 0000000..c2d7e19 --- /dev/null +++ b/src/Tools/DataEditorToolBoxButton.cs @@ -0,0 +1,20 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Core.UI.Tool; + + /// Puts the data editor window on the in-game tool box. + public class DataEditorToolBoxButton : IToolBoxButton + { + /// + public string ToolId => "schema_data_editor"; + + /// + public string IconPath => ""; + + /// + public string LocaleKey => "tools/schema/data_editor/title"; + + /// + public bool IsVisible() => true; + } +} diff --git a/src/Tools/DataEditorToolProvider.cs b/src/Tools/DataEditorToolProvider.cs new file mode 100644 index 0000000..78b224c --- /dev/null +++ b/src/Tools/DataEditorToolProvider.cs @@ -0,0 +1,22 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Core.UI.Tool; + using Cthangover.Mods.Interface.Tool; + using Godot; + + /// Puts the data editor window into the tools menu. + public class DataEditorToolProvider : IToolProvider + { + /// + public string Id => "schema_data_editor"; + + /// + public string LocaleKey => "tools/schema/data_editor/title"; + + /// + public Window CreateWindow() => new DataEditorWindow(); + + /// + public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this); + } +} diff --git a/src/Tools/PaletteToolBoxButton.cs b/src/Tools/PaletteToolBoxButton.cs new file mode 100644 index 0000000..575192b --- /dev/null +++ b/src/Tools/PaletteToolBoxButton.cs @@ -0,0 +1,20 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Core.UI.Tool; + + /// Puts the command palette on the in-game tool box. + public class PaletteToolBoxButton : IToolBoxButton + { + /// + public string ToolId => "schema_palette"; + + /// + public string IconPath => ""; + + /// + public string LocaleKey => "tools/schema/palette/title"; + + /// + public bool IsVisible() => true; + } +} diff --git a/src/Tools/PaletteToolProvider.cs b/src/Tools/PaletteToolProvider.cs new file mode 100644 index 0000000..ef07a18 --- /dev/null +++ b/src/Tools/PaletteToolProvider.cs @@ -0,0 +1,22 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Core.UI.Tool; + using Cthangover.Mods.Interface.Tool; + using Godot; + + /// Puts the command palette into the tools menu. + public class PaletteToolProvider : IToolProvider + { + /// + public string Id => "schema_palette"; + + /// + public string LocaleKey => "tools/schema/palette/title"; + + /// + public Window CreateWindow() => new CommandPaletteWindow(); + + /// + public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this); + } +} diff --git a/test_suites/edit/manifest.json b/test_suites/edit/manifest.json new file mode 100644 index 0000000..9881669 --- /dev/null +++ b/test_suites/edit/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "Schema Tools Test", + "description": "GUI test of the schema tools in rpg_plus: the Data Editor window lists the described kinds, a new item in rpg_plus_game gets an equip type and a trait through the form (a list card with a stat picker), is saved and is gear the game knows at once; the command palette builds 'action stat.add stat=str value=2', reports an unknown stat as an error, and inserts the line into an open scenario editor. The new file and the locale edits are removed again. Activated by --itest=schema_tools_edit with --game=rpg_plus --new.", + "sources": [ + "src/*.cs" + ], + "depends": [ + "rpg_plus_game>=1" + ] +} diff --git a/test_suites/edit/src/SchemaToolsTestDriver.cs b/test_suites/edit/src/SchemaToolsTestDriver.cs new file mode 100644 index 0000000..86c8b8e --- /dev/null +++ b/test_suites/edit/src/SchemaToolsTestDriver.cs @@ -0,0 +1,216 @@ +namespace Cthangover.Tools.Schemas.Tests +{ + using Cthangover.Mods.Schemas; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using Cthangover.Core.Bootstrap; + using Cthangover.Core.Mods; + using Cthangover.Core.Mods.Resolvers; + using Cthangover.Core.UI.Tool.ScenarioEditor; + using Cthangover.Core.Utils; + using Cthangover.Mods.Equipment; + using Cthangover.Mods.Interface.Tool; + using Cthangover.Mods.Stats; + using Cthangover.Tools.Services; + using Godot; + + /// + /// Drives the Data Editor window and the command palette like an author: a new item gets + /// gear fields that two different mods described, the game sees it as gear right after + /// Save, and the palette writes and checks a stats line. Everything the test writes is + /// removed again, the files it changed are restored byte for byte. + /// + public partial class SchemaToolsTestDriver : Node + { + private const float TickSeconds = 0.15f; + private const float TimeoutSeconds = 60f; + private const string Mod = "rpg_plus_game"; + private const string NewFile = "items/rpg_plus_game.json"; + private const string NewId = "rpg_plus_game/new_item"; + private const string ScenarioKey = "rpg_plus_game:scenarios/rpg_plus/events/seer.scenario"; + + private readonly List failures = new(); + private readonly Dictionary backups = new(); + private int passed; + private int step; + private double elapsed; + private bool newFileExisted; + private bool itemsDirExisted; + private DataEditorWindow editor; + private CommandPaletteWindow palette; + private ScenarioEditorWindow scenarios; + + public SchemaToolsTestDriver() + { + Name = "SchemaToolsTestDriver"; + var timer = new Timer { WaitTime = TickSeconds, Autostart = true }; + AddChild(timer); + timer.Timeout += Tick; + } + + private void Tick() + { + if (step < 0) + return; + elapsed += TickSeconds; + if (elapsed > TimeoutSeconds) + { + Fail($"timeout at step {step}"); + Cleanup(); + Finish(); + return; + } + try + { + RunStep(); + } + catch (System.Exception ex) + { + Fail($"step {step}: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"); + Cleanup(); + Finish(); + } + } + + private void RunStep() + { + switch (step) + { + case 0: + if (!Boot.IsReady) + return; + foreach (var file in ModFileService.ListFiles(Mod, "locale", ".properties")) + backups[file] = ModFileService.ReadText(Mod, file); + newFileExisted = ModFileService.ReadText(Mod, NewFile) != null; + itemsDirExisted = Directory.Exists(Path.GetDirectoryName(ModFileService.GetWritablePath(Mod, NewFile)) ?? string.Empty); + Check(!newFileExisted, "the game mod has no items file of its own yet"); + editor = ToolWindow.Open(); + step++; + return; + case 1: + if (!editor.IsInsideTree()) + return; + var kinds = SchemaCatalog.Current.Kinds; + Check(new[] { "stat", "archetype", "equip_slot", "item", "character", "action", "shop" }.All(kinds.ContainsKey), "the editor lists the kinds of five mods' schemas"); + editor.OpenKind("item"); + editor.OpenMod(Mod); + Check(editor.KindId == "item" && editor.ModId == Mod, "items of the game mod are open"); + editor.AddNew(); + Check(editor.SelectedId == NewId, $"New adds {NewId} (got {editor.SelectedId})"); + Type(editor, "Text_Equip", "sword"); + var list = editor.FindChild("List_Traits", true, false); + Check(list != null, "the equipment mod's Traits field is a list editor in the item form"); + (list?.FindChild("Add", true, false) as Button)?.EmitSignal(BaseButton.SignalName.Pressed); + step++; + return; + case 2: + var card = editor.FindChild("List_Traits", true, false)?.FindChild("Card0", true, false); + Check(card != null, "Add made a trait card"); + var picker = card?.FindChild("Picker_Stat", true, false) as OptionButton; + var index = Enumerable.Range(0, picker?.ItemCount ?? 0).FirstOrDefault(i => picker.GetItemText(i).EndsWith(" atk") || picker.GetItemText(i) == "atk"); + Check(picker != null && index > 0, "the trait's stat picker offers the pack's stats"); + picker?.Select(index); + picker?.EmitSignal(OptionButton.SignalName.ItemSelected, index); + Type(card, "Text_Add", "3"); + var data = editor.Document.Find(NewId)?.Data; + Check(data?["Equip"]?.ToString() == "sword", "the form wrote the equip type"); + Check(data?["Traits"]?[0]?["Stat"]?.ToString() == "atk" && data?["Traits"]?[0]?["Add"]?.ToString() == "3", $"the form wrote the trait: {data?["Traits"]?.ToJsonString()}"); + Check(editor.Save(), "Save writes the mod"); + Check(EquipmentService.IsEquipment(NewId), "the game knows the new item as gear right after saving"); + var traits = ExtraData.Traits(EquipmentService.Item(NewId)?.ExtraProperties, NewId); + Check(traits.Count == 1 && traits[0].Stat == "atk" && traits[0].Add == 3, "with its trait"); + var written = ModFileService.ReadText(Mod, NewFile) ?? string.Empty; + Check(written.Contains("\"Kind\": \"item\"") && !written.Contains("\r"), "the new file declares its kind and has LF lines"); + palette = ToolWindow.Open(); + scenarios = ToolWindow.Open(); + step++; + return; + case 3: + if (!palette.IsInsideTree() || !scenarios.IsInsideTree()) + return; + scenarios.OpenScenario(ScenarioKey); + palette.Choose("action:stat.add"); + palette.SetValue("stat", "str"); + palette.SetValue("value", "2"); + Check(palette.Line == "action stat.add stat=str value=2", $"the palette writes the line ({palette.Line})"); + Check((palette.FindChild("Issues", true, false) as Label)?.Text.StartsWith("✔") == true, "and finds nothing wrong with it"); + palette.SetValue("stat", "strenght"); + Check((palette.FindChild("Issues", true, false) as Label)?.Text.Contains("✖") == true, "a misspelt stat is reported as an error"); + palette.SetValue("stat", "str"); + step++; + return; + case 4: + var tabs = scenarios.FindChildren("*", "", true, false).OfType().FirstOrDefault(); + if (tabs == null || !tabs.HasActiveFile) + { + if (elapsed > 30) + Fail("the scenario editor did not open the seer's scenario"); + else + return; + } + Check(palette.InsertIntoEditor(), "Insert puts the line into the open scenario"); + Check(tabs?.CurrentText.Contains("action stat.add stat=str value=2") == true, "the editor's text has the line"); + Check(ModFileService.ReadText(Mod, "scenarios/rpg_plus/events/seer.scenario")?.Contains("stat.add") == false, "nothing is written to disk before the author saves"); + Cleanup(); + Finish(); + return; + } + } + + private static void Type(Node root, string name, string text) + { + if (root?.FindChild(name, true, false) is not LineEdit edit) + return; + edit.Text = text; + edit.EmitSignal(LineEdit.SignalName.TextChanged, text); + } + + private void Cleanup() + { + if (step < 0) + return; + foreach (var window in new Node[] { editor, palette, scenarios }.Where(w => w != null && IsInstanceValid(w))) + window.QueueFree(); + var path = ModFileService.GetWritablePath(Mod, NewFile); + if (!newFileExisted && path != null && File.Exists(path)) + File.Delete(path); + // The save created the folder too; only that empty folder of this run goes away. + var folder = path == null ? null : Path.GetDirectoryName(path); + if (!itemsDirExisted && folder != null && Directory.Exists(folder) && !Directory.EnumerateFileSystemEntries(folder).Any()) + Directory.Delete(folder); + foreach (var backup in backups) + ModFileService.WriteText(Mod, backup.Key, backup.Value); + ModManager.Instance.GetResolver()?.ClearCache(); + ModManager.Instance.GetResolver()?.ClearCache(); + GameEntityIndex.Refresh(); + LocaleFileService.ReloadGame(); + } + + private void Check(bool condition, string what) + { + if (condition) + { + passed++; + GameLogger.Log("TEST", $"[PASS] {what}"); + } + else + { + Fail(what); + } + } + + private void Fail(string what) + { + failures.Add(what); + GameLogger.Log("TEST", $"[FAIL] {what}", LogLevel.Error); + } + + private void Finish() + { + step = -1; + GameLogger.Log("TEST", $"[TEST_RESULT] name=schema_tools_edit passed={passed} failed={failures.Count}"); + GetTree().Quit(failures.Count == 0 ? 0 : 1); + } + } +} diff --git a/test_suites/edit/src/SchemaToolsTestInitializer.cs b/test_suites/edit/src/SchemaToolsTestInitializer.cs new file mode 100644 index 0000000..019704d --- /dev/null +++ b/test_suites/edit/src/SchemaToolsTestInitializer.cs @@ -0,0 +1,28 @@ +namespace Cthangover.Tools.Schemas.Tests +{ + using System.Linq; + using Cthangover.Core.Mods; + using Cthangover.Core.Utils; + using Godot; + + /// Mounts when the game runs with --itest=schema_tools_edit. + public class SchemaToolsTestInitializer : IModInitializer + { + private static bool driverQueued; + + /// + public void OnModLoaded(string modId) + { + } + + /// + public void OnModResourcesReady() + { + if (driverQueued || !OS.GetCmdlineUserArgs().Any(a => a == "--itest=schema_tools_edit")) + return; + driverQueued = true; + GameLogger.Log("TEST", "SchemaToolsTestInitializer: activating schema_tools_edit"); + (Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new SchemaToolsTestDriver()); + } + } +} diff --git a/tests/SchemaLineTests.cs b/tests/SchemaLineTests.cs new file mode 100644 index 0000000..dbf279c --- /dev/null +++ b/tests/SchemaLineTests.cs @@ -0,0 +1,62 @@ +namespace Cthangover.Tools.Schemas.Tests +{ + using Cthangover.Mods.Schemas; + using System.Collections.Generic; + using Cthangover.Core.Mods.Testing; + using Cthangover.Mods.Dialog.Scenarios; + + /// Lines the palette writes are DSL the scenario parser reads back with the same values. + public class SchemaLineTests + { + private static readonly ActionSchema Battle = new() + { + Id = "rpg.battle", + Params = new List { new() { Key = "enemies" }, new() { Key = "win_flag" }, new() { Key = "note" } }, + }; + + private static readonly CommandSchema Check = new() + { + Id = "check", + Positional = new List { new() { Key = "stat" }, new() { Key = "difficulty" } }, + Params = new List { new() { Key = "else" } }, + Arrow = "k", + }; + + [ModTest("line/action_order_and_empties")] + static void ActionOrderAndEmpties(ModTestContext ctx) + { + var line = SchemaLine.Action(Battle, new Dictionary { ["win_flag"] = "won", ["enemies"] = "a,b", ["note"] = "" }); + ModAssert.Equal("action rpg.battle enemies=a,b win_flag=won", line, "schema order, empty left out"); + } + + [ModTest("line/round_trip_through_the_parser")] + static void RoundTrip(ModTestContext ctx) + { + var line = SchemaLine.Action(Battle, new Dictionary { ["enemies"] = "rpg_wolf", ["note"] = "two \"words\"" }); + var positional = new List(); + var named = new Dictionary(); + var command = ScenarioParser.SplitLine(line, positional, named, out _); + ModAssert.Equal("action", command, "command"); + ModAssert.Equal("rpg.battle", positional[0], "action name"); + ModAssert.Equal("rpg_wolf", named["enemies"], "plain value"); + ModAssert.Equal("two \"words\"", named["note"], "a value with spaces and quotes survives"); + + var check = SchemaLine.Command(Check, new Dictionary { ["stat"] = "cha", ["difficulty"] = "12", ["else"] = "fail" }, ":ok"); + ModAssert.Equal("check cha 12 else=fail -> :ok", check, "command line"); + positional.Clear(); + named.Clear(); + ScenarioParser.SplitLine(check, positional, named, out var arrow); + ModAssert.Equal("12", positional[1], "positional"); + ModAssert.Equal("ok", arrow, "arrow"); + } + + [ModTest("line/quote")] + static void QuoteRules(ModTestContext ctx) + { + ModAssert.Equal("plain", SchemaLine.Quote("plain"), "no quotes needed"); + ModAssert.Equal("\"a b\"", SchemaLine.Quote("a b"), "spaces"); + ModAssert.Equal("\"\"", SchemaLine.Quote(""), "empty"); + ModAssert.Equal("\"say \\\"hi\\\"\"", SchemaLine.Quote("say \"hi\""), "inner quotes escaped"); + } + } +}