From 033192dcc98de040af84cb1957ff7d2d666bac72 Mon Sep 17 00:00:00 2001 From: iisaev Date: Sat, 24 Oct 2026 10:01:09 +0200 Subject: [PATCH] Schema field editors (scalar, list, map, locale); entity editors show fields that other mods' schemas add to their kind Co-Authored-By: Claude Opus 5.5 --- locale/schema_en.properties | 6 + locale/schema_es.properties | 6 + locale/schema_ja.properties | 6 + locale/schema_ko.properties | 6 + locale/schema_ru.properties | 6 + locale/schema_vi.properties | 6 + locale/schema_zh.properties | 6 + manifest.json | 2 +- src/EntityEditor/EntityEditorWindow.cs | 40 ++++- src/Schemas/ISchemaFieldEditor.cs | 18 +++ src/Schemas/ListFieldEditor.cs | 173 +++++++++++++++++++++ src/Schemas/LocaleFieldEditor.cs | 110 ++++++++++++++ src/Schemas/MapFieldEditor.cs | 128 ++++++++++++++++ src/Schemas/ScalarFieldEditor.cs | 28 ++++ src/Schemas/SchemaFieldControl.cs | 202 +++++++++++++++++++++++++ src/Schemas/SchemaFieldEditors.cs | 32 ++++ 16 files changed, 773 insertions(+), 2 deletions(-) create mode 100644 locale/schema_en.properties create mode 100644 locale/schema_es.properties create mode 100644 locale/schema_ja.properties create mode 100644 locale/schema_ko.properties create mode 100644 locale/schema_ru.properties create mode 100644 locale/schema_vi.properties create mode 100644 locale/schema_zh.properties create mode 100644 src/Schemas/ISchemaFieldEditor.cs create mode 100644 src/Schemas/ListFieldEditor.cs create mode 100644 src/Schemas/LocaleFieldEditor.cs create mode 100644 src/Schemas/MapFieldEditor.cs create mode 100644 src/Schemas/ScalarFieldEditor.cs create mode 100644 src/Schemas/SchemaFieldControl.cs create mode 100644 src/Schemas/SchemaFieldEditors.cs diff --git a/locale/schema_en.properties b/locale/schema_en.properties new file mode 100644 index 0000000..0e31d44 --- /dev/null +++ b/locale/schema_en.properties @@ -0,0 +1,6 @@ +tools/entity_editor/section_schema = Fields of other mods +tools/entity_editor/schema_hint = These fields are described in the schemas of the mods: {0}. The game reads them from the same JSON. +tools/schema/add_ref = + add… +tools/schema/list_add = + Add +tools/schema/list_remove = ✕ +tools/schema/map_add = + Stat diff --git a/locale/schema_es.properties b/locale/schema_es.properties new file mode 100644 index 0000000..4a2f377 --- /dev/null +++ b/locale/schema_es.properties @@ -0,0 +1,6 @@ +tools/entity_editor/section_schema = Campos de otros mods +tools/entity_editor/schema_hint = Estos campos los describen los esquemas de los mods: {0}. El juego los lee del mismo JSON. +tools/schema/add_ref = + añadir… +tools/schema/list_add = + Añadir +tools/schema/list_remove = ✕ +tools/schema/map_add = + Estadística diff --git a/locale/schema_ja.properties b/locale/schema_ja.properties new file mode 100644 index 0000000..b2d080d --- /dev/null +++ b/locale/schema_ja.properties @@ -0,0 +1,6 @@ +tools/entity_editor/section_schema = 他の MOD のフィールド +tools/entity_editor/schema_hint = これらのフィールドは次の MOD のスキーマが記述している:{0}。ゲームは同じ JSON から読む。 +tools/schema/add_ref = + 追加… +tools/schema/list_add = + 追加 +tools/schema/list_remove = ✕ +tools/schema/map_add = + ステータス diff --git a/locale/schema_ko.properties b/locale/schema_ko.properties new file mode 100644 index 0000000..5d6597e --- /dev/null +++ b/locale/schema_ko.properties @@ -0,0 +1,6 @@ +tools/entity_editor/section_schema = 다른 모드의 필드 +tools/entity_editor/schema_hint = 이 필드는 다음 모드들의 스키마가 설명합니다: {0}. 게임은 같은 JSON에서 읽습니다. +tools/schema/add_ref = + 추가… +tools/schema/list_add = + 추가 +tools/schema/list_remove = ✕ +tools/schema/map_add = + 능력치 diff --git a/locale/schema_ru.properties b/locale/schema_ru.properties new file mode 100644 index 0000000..8dfdc13 --- /dev/null +++ b/locale/schema_ru.properties @@ -0,0 +1,6 @@ +tools/entity_editor/section_schema = Поля других модов +tools/entity_editor/schema_hint = Эти поля описали в своих схемах моды: {0}. Игра читает их из того же JSON. +tools/schema/add_ref = + добавить… +tools/schema/list_add = + Добавить +tools/schema/list_remove = ✕ +tools/schema/map_add = + Стат diff --git a/locale/schema_vi.properties b/locale/schema_vi.properties new file mode 100644 index 0000000..9fa7df1 --- /dev/null +++ b/locale/schema_vi.properties @@ -0,0 +1,6 @@ +tools/entity_editor/section_schema = Trường của mod khác +tools/entity_editor/schema_hint = Các trường này do lược đồ của các mod mô tả: {0}. Game đọc chúng từ cùng tệp JSON. +tools/schema/add_ref = + thêm… +tools/schema/list_add = + Thêm +tools/schema/list_remove = ✕ +tools/schema/map_add = + Chỉ số diff --git a/locale/schema_zh.properties b/locale/schema_zh.properties new file mode 100644 index 0000000..d3b5d74 --- /dev/null +++ b/locale/schema_zh.properties @@ -0,0 +1,6 @@ +tools/entity_editor/section_schema = 其他模组的字段 +tools/entity_editor/schema_hint = 这些字段由以下模组的模式描述:{0}。游戏从同一个 JSON 读取它们。 +tools/schema/add_ref = + 添加… +tools/schema/list_add = + 添加 +tools/schema/list_remove = ✕ +tools/schema/map_add = + 属性 diff --git a/manifest.json b/manifest.json index cb86823..7915e20 100644 --- a/manifest.json +++ b/manifest.json @@ -11,7 +11,7 @@ "test_sources": [ "tests/*.cs" ], - "depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"], + "depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1", "schema_base>=1"], "test_suites": [ {"id": "windows", "activate": ["--itest=tools_windows"]}, {"id": "ingame_play", "activate": ["--itest=ingame_play"]}, diff --git a/src/EntityEditor/EntityEditorWindow.cs b/src/EntityEditor/EntityEditorWindow.cs index d9e8129..25ba5fd 100644 --- a/src/EntityEditor/EntityEditorWindow.cs +++ b/src/EntityEditor/EntityEditorWindow.cs @@ -9,7 +9,9 @@ using Cthangover.Core.UI.Theming; using Cthangover.Core.Utils; using Cthangover.Mods.Interface.Theming; using Cthangover.Mods.Interface.Tool; +using Cthangover.Mods.Schemas; using Cthangover.Tools.Common; +using Cthangover.Tools.Schemas; using Cthangover.Tools.Services; using Godot; @@ -30,6 +32,12 @@ namespace Cthangover.Tools.EntityEditor /// editable, and every property the form does not know is shown as JSON under /// "other fields", edited there and written back as it is. /// + /// Fields other mods describe for the kind in their schemas (schemas/*.json: the + /// equipment mod adds Equip and Traits to items, the stats add Archetype + /// and Stats to characters) get their own block, "fields of other mods", with the + /// schema editors, so a data editor grows with the mods loaded next to it without + /// knowing them. + /// /// Every change is undoable (Ctrl+Z / Ctrl+Y): one undo step per editing /// session of one entity, and one per add, duplicate or delete. /// @@ -44,6 +52,7 @@ namespace Cthangover.Tools.EntityEditor private readonly Dictionary previews = new(); private readonly Dictionary icons = new(); private readonly List mods = new(); + private readonly List schemaEditors = new(); private OptionButton modPicker; private FilteredList list; @@ -144,6 +153,7 @@ namespace Cthangover.Tools.EntityEditor idEdit.AddThemeFontOverride("font", ToolUi.Mono()); foreach (var field in fields) BuildField(field); + BuildSchemaFields(); form.AddChild(ToolUi.Section("tools/entity_editor/section_other")); form.AddChild(ToolUi.Hint(ToolUi.Tr("tools/entity_editor/other_hint"))); @@ -188,6 +198,20 @@ namespace Cthangover.Tools.EntityEditor } } + private void BuildSchemaFields() + { + var described = SchemaCatalog.Current.FieldsOf(kind) + .Where(f => !string.Equals(f.Key, "Id", StringComparison.OrdinalIgnoreCase) && !fields.Any(e => string.Equals(e.Key, f.Key, StringComparison.OrdinalIgnoreCase))) + .ToList(); + if (described.Count == 0) + return; + form.AddChild(ToolUi.Section("tools/entity_editor/section_schema")); + var sources = string.Join(", ", described.Select(f => f.SourceMod).Where(m => m != null).Distinct()); + form.AddChild(ToolUi.Hint(string.Format(ToolUi.Tr("tools/entity_editor/schema_hint"), sources))); + foreach (var field in described) + schemaEditors.Add(SchemaFieldEditors.Create(form, field, WriteForm)); + } + private void BuildPicture(EntityField field) { var row = new HBoxContainer(); @@ -257,6 +281,8 @@ namespace Cthangover.Tools.EntityEditor languages = LocaleFileService.Languages(mod); foreach (var field in fields.Where(f => f.Kind == EntityFieldKind.LocaleText)) BuildTextRows(field); + foreach (var locale in schemaEditors.OfType()) + locale.Open(document, mod, languages); MarkClean(); selected = null; RefreshList(); @@ -337,6 +363,8 @@ namespace Cthangover.Tools.EntityEditor idEdit.Text = record.Id; foreach (var field in fields) ShowField(field, record.Data); + foreach (var editor in schemaEditors) + editor.Load(record.Data.FirstOrDefault(p => string.Equals(p.Key, editor.Field.Key, StringComparison.OrdinalIgnoreCase)).Value); otherEdit.Text = OtherJson(record.Data); suppress = false; } @@ -451,6 +479,15 @@ namespace Cthangover.Tools.EntityEditor data["Id"] = idEdit.Text.Trim(); foreach (var field in fields) WriteField(field, data); + foreach (var editor in schemaEditors) + { + var key = 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(key); + else + data[key] = value; + } SetDirty(); RefreshList(); } @@ -524,7 +561,8 @@ namespace Cthangover.Tools.EntityEditor return EntityDocument.Serialize(other).TrimEnd(); } - private bool IsFormKey(string key) => key == "Id" || fields.Any(f => f.Key == key); + private bool IsFormKey(string key) + => key == "Id" || fields.Any(f => f.Key == key) || schemaEditors.Any(e => string.Equals(e.Field.Key, key, StringComparison.OrdinalIgnoreCase)); private void OnOtherChanged() { diff --git a/src/Schemas/ISchemaFieldEditor.cs b/src/Schemas/ISchemaFieldEditor.cs new file mode 100644 index 0000000..a2be780 --- /dev/null +++ b/src/Schemas/ISchemaFieldEditor.cs @@ -0,0 +1,18 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Mods.Schemas; + using System.Text.Json.Nodes; + + /// One field's editor in the data editor form: shows the field's JSON and gives it back edited. + public interface ISchemaFieldEditor + { + /// The described field. + FieldSchema Field { get; } + + /// Shows (null: the field is absent). + void Load(JsonNode node); + + /// The edited value, or null to remove the key. + JsonNode Save(); + } +} diff --git a/src/Schemas/ListFieldEditor.cs b/src/Schemas/ListFieldEditor.cs new file mode 100644 index 0000000..2f25f46 --- /dev/null +++ b/src/Schemas/ListFieldEditor.cs @@ -0,0 +1,173 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Mods.Schemas; + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Text.Json.Nodes; + using Cthangover.Core.UI.Theming; + using Cthangover.Mods.Interface.Theming; + using Cthangover.Tools.Common; + using Godot; + + /// + /// Edits a list field — an array of objects described by + /// (an item's traits, an archetype's learnings, a shop's goods) — as a stack of small + /// cards with an add and a remove button, each card a form of the sub-fields. + /// + /// The values type is the same editor over the engine's older list of + /// "Key=Value" strings (a battle action's Values): known keys become fields, + /// entries the schema does not know are kept as they were. + /// + public class ListFieldEditor : ISchemaFieldEditor + { + private readonly FieldSchema field; + private readonly Action changed; + private readonly VBoxContainer cards; + private readonly List> rows = new(); + private readonly List unknownValues = new(); + private bool suppress; + + /// + public FieldSchema Field => field; + + /// Adds the editor for to . + public ListFieldEditor(Container parent, FieldSchema field, Action changed) + { + this.field = field; + this.changed = changed; + var box = new VBoxContainer { Name = "List_" + field.Key }; + box.AddThemeConstantOverride("separation", UiTokens.Space2); + cards = new VBoxContainer(); + cards.AddThemeConstantOverride("separation", UiTokens.Space2); + box.AddChild(cards); + var add = ToolUi.Button("tools/schema/list_add", AddEmpty, ButtonKind.Secondary); + add.Name = "Add"; + add.SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin; + box.AddChild(add); + ToolUi.Field(parent, string.IsNullOrEmpty(field.Name) ? field.Key : field.Name, box, field.Description); + } + + private bool IsValues => string.Equals(field.Type, "values", StringComparison.OrdinalIgnoreCase); + + /// Shows the array (null or not an array: empty). + public void Load(JsonNode node) + { + suppress = true; + Clear(); + unknownValues.Clear(); + if (node is JsonArray array) + { + if (IsValues) + LoadValues(array); + else + foreach (var item in array.OfType()) + AddCard(item); + } + suppress = false; + } + + /// The edited array, or null when it has no entries (the key is then removed). + public JsonNode Save() + { + var array = new JsonArray(); + if (IsValues) + { + foreach (var card in rows) + { + foreach (var control in card.Where(c => c.Value.Length > 0)) + array.Add(control.Field.Key + "=" + control.Value); + } + foreach (var raw in unknownValues) + array.Add(raw); + return array.Count == 0 ? null : array; + } + foreach (var card in rows) + { + var item = new JsonObject(); + foreach (var control in card.Where(c => c.Value.Length > 0)) + item[control.Field.Key] = SchemaJson.ToNode(control.Field, control.Value); + if (item.Count > 0) + array.Add(item); + } + return array.Count == 0 ? null : array; + } + + private void LoadValues(JsonArray array) + { + var known = new JsonObject(); + foreach (var raw in array.Select(n => n?.ToString()).Where(s => s != null)) + { + var eq = raw.IndexOf('='); + var key = eq > 0 ? raw.Substring(0, eq) : raw; + var def = field.Of?.FirstOrDefault(f => string.Equals(f.Key, key, StringComparison.OrdinalIgnoreCase)); + if (def == null || eq <= 0) + unknownValues.Add(raw); + else + known[def.Key] = raw.Substring(eq + 1); + } + AddCard(known); + } + + private void AddEmpty() + { + AddCard(new JsonObject()); + if (!suppress) + changed?.Invoke(); + } + + private void AddCard(JsonObject item) + { + if (IsValues && rows.Count > 0) + return; + var card = new PanelContainer { Name = "Card" + rows.Count }; + card.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusSm, UiTokens.Well, UiTokens.LineSoft)); + var column = new VBoxContainer(); + card.AddChild(column); + var controls = new List(); + foreach (var sub in field.Of ?? new List()) + { + var control = new SchemaFieldControl(column, sub, OnChanged); + control.Value = SchemaJson.ToText(Find(item, sub.Key)); + controls.Add(control); + } + if (!IsValues) + { + var remove = ToolUi.Button("tools/schema/list_remove", () => Remove(card, controls), ButtonKind.Secondary); + remove.Name = "Remove"; + remove.SizeFlagsHorizontal = Control.SizeFlags.ShrinkEnd; + column.AddChild(remove); + } + rows.Add(controls); + cards.AddChild(card); + } + + private static JsonNode Find(JsonObject item, string key) + => item.FirstOrDefault(p => string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase)).Value; + + private void Remove(Control card, List controls) + { + rows.Remove(controls); + cards.RemoveChild(card); + card.QueueFree(); + changed?.Invoke(); + } + + private void OnChanged() + { + if (!suppress) + changed?.Invoke(); + } + + private void Clear() + { + rows.Clear(); + foreach (var child in cards.GetChildren()) + { + cards.RemoveChild(child); + child.QueueFree(); + } + } + } +} diff --git a/src/Schemas/LocaleFieldEditor.cs b/src/Schemas/LocaleFieldEditor.cs new file mode 100644 index 0000000..c98be21 --- /dev/null +++ b/src/Schemas/LocaleFieldEditor.cs @@ -0,0 +1,110 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Mods.Schemas; + using System; + using System.Collections.Generic; + using System.Text.Json.Nodes; + using Cthangover.Core.UI.Theming; + using Cthangover.Tools.Common; + using Cthangover.Tools.EntityEditor; + using Cthangover.Tools.Services; + using Godot; + + /// + /// Edits a locale field: the locale key the JSON holds and, below it, the text of + /// that key in every language of the mod — the way the item and character editors do, + /// so a beginner types names and a professional can still rename the key. Texts go + /// through the and are written by the window's Save. + /// + public class LocaleFieldEditor : ISchemaFieldEditor + { + private readonly FieldSchema field; + private readonly Action changed; + private readonly LineEdit keyEdit; + private readonly VBoxContainer texts; + private readonly Dictionary edits = new(); + private EntityDocument document; + private string modId; + private bool suppress; + + /// + public FieldSchema Field => field; + + /// Adds the editor for to . + public LocaleFieldEditor(Container parent, FieldSchema field, Action changed) + { + this.field = field; + this.changed = changed; + keyEdit = ToolUi.Field(parent, string.IsNullOrEmpty(field.Name) ? field.Key : field.Name, ToolUi.Text(string.Empty, OnTyped), "tools/entity_editor/locale_key_tip"); + keyEdit.Name = "Key_" + field.Key; + keyEdit.AddThemeFontOverride("font", ToolUi.Mono()); + texts = new VBoxContainer(); + parent.AddChild(texts); + } + + /// Builds one text row per language of . + public void Open(EntityDocument doc, string mod, IEnumerable languages) + { + document = doc; + modId = mod; + foreach (var child in texts.GetChildren()) + { + texts.RemoveChild(child); + child.QueueFree(); + } + edits.Clear(); + foreach (var language in languages) + { + var row = new HBoxContainer(); + row.AddThemeConstantOverride("separation", UiTokens.Space3); + row.AddChild(new Label { Text = string.Format(ToolUi.Tr("tools/entity_editor/text_in"), language), CustomMinimumSize = new Vector2(ToolUi.LabelWidth, 0), HorizontalAlignment = HorizontalAlignment.Right }); + var edit = ToolUi.Text(string.Empty, OnTyped); + edit.Name = "Text_" + field.Key + "_" + language; + edit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + row.AddChild(edit); + texts.AddChild(row); + edits[language] = edit; + } + } + + /// Shows the key in and its texts. + public void Load(JsonNode node) + { + suppress = true; + var key = node?.ToString() ?? string.Empty; + keyEdit.Text = key; + foreach (var pair in edits) + pair.Value.Text = TextOf(pair.Key, key); + suppress = false; + } + + /// The key for the JSON (null when empty); texts are put into the document. + public JsonNode Save() + { + var key = keyEdit.Text.Trim(); + if (key.Length == 0) + return null; + foreach (var pair in edits) + document?.SetText(pair.Key, key, pair.Value.Text); + return JsonValue.Create(key); + } + + private string TextOf(string language, string key) + { + if (string.IsNullOrEmpty(key) || document == null) + return string.Empty; + var text = document.GetText(language, key); + if (text != null) + return text; + text = LocaleFileService.Read(modId, language, key) ?? string.Empty; + document.LoadText(language, key, text); + return text; + } + + private void OnTyped(string text) + { + if (!suppress) + changed?.Invoke(); + } + } +} diff --git a/src/Schemas/MapFieldEditor.cs b/src/Schemas/MapFieldEditor.cs new file mode 100644 index 0000000..4a25715 --- /dev/null +++ b/src/Schemas/MapFieldEditor.cs @@ -0,0 +1,128 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Mods.Schemas; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text.Json.Nodes; + using Cthangover.Core.UI.Theming; + using Cthangover.Mods.Interface.Theming; + using Cthangover.Tools.Common; + using Godot; + + /// + /// Edits a map field — an object whose keys are ids of + /// and whose values are numbers or formulas (a character's own Stats, an archetype's + /// growth): one row per key with a key picker, a value box and a remove button. A number + /// is written as a number, anything else as a formula text. + /// + public class MapFieldEditor : ISchemaFieldEditor + { + private readonly FieldSchema field; + private readonly Action changed; + private readonly VBoxContainer lines; + private readonly List<(OptionButton Key, List Keys, LineEdit Value, Control Row)> rows = new(); + private bool suppress; + + /// + public FieldSchema Field => field; + + /// Adds the editor for to . + public MapFieldEditor(Container parent, FieldSchema field, Action changed) + { + this.field = field; + this.changed = changed; + var box = new VBoxContainer { Name = "Map_" + field.Key }; + lines = new VBoxContainer(); + lines.AddThemeConstantOverride("separation", UiTokens.Space1); + box.AddChild(lines); + var add = ToolUi.Button("tools/schema/map_add", AddEmpty, ButtonKind.Secondary); + add.Name = "Add"; + add.SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin; + box.AddChild(add); + ToolUi.Field(parent, string.IsNullOrEmpty(field.Name) ? field.Key : field.Name, box, field.Description); + } + + /// Shows the object . + public void Load(JsonNode node) + { + suppress = true; + foreach (var row in rows) + { + lines.RemoveChild(row.Row); + row.Row.QueueFree(); + } + rows.Clear(); + if (node is JsonObject obj) + { + foreach (var pair in obj) + AddRow(pair.Key, SchemaJson.ToText(pair.Value)); + } + suppress = false; + } + + /// The edited object, or null when empty. + public JsonNode Save() + { + var obj = new JsonObject(); + foreach (var row in rows) + { + var key = row.Key.Selected >= 0 && row.Key.Selected < row.Keys.Count ? row.Keys[row.Key.Selected] : null; + if (!string.IsNullOrEmpty(key) && row.Value.Text.Trim().Length > 0) + obj[key] = SchemaJson.NumberOrText(row.Value.Text.Trim()); + } + return obj.Count == 0 ? null : obj; + } + + private void AddEmpty() + { + AddRow(null, string.Empty); + changed?.Invoke(); + } + + private void AddRow(string key, string value) + { + var row = new HBoxContainer { Name = "Row" + rows.Count }; + row.AddThemeConstantOverride("separation", UiTokens.Space2); + var keys = new List { string.Empty }; + if (!string.IsNullOrEmpty(key) && !GameEntityIndex.Of(field.Kind).ContainsKey(key)) + keys.Add(key); + keys.AddRange(GameEntityIndex.Of(field.Kind).Keys); + var picker = new OptionButton { CustomMinimumSize = new Vector2(200, 0) }; + foreach (var k in keys) + picker.AddItem(k.Length == 0 ? "—" : GameEntityIndex.Label(field.Kind, k)); + picker.Select(Math.Max(0, keys.FindIndex(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase)))); + picker.ItemSelected += OnPicked; + row.AddChild(picker); + var edit = ToolUi.Text(field.Default ?? string.Empty, OnTyped); + edit.Text = value ?? string.Empty; + edit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + edit.AddThemeFontOverride("font", ToolUi.Mono()); + row.AddChild(edit); + var entry = (picker, keys, edit, (Control)row); + row.AddChild(ToolUi.Button("tools/schema/list_remove", () => Remove(entry), ButtonKind.Secondary)); + rows.Add(entry); + lines.AddChild(row); + } + + private void Remove((OptionButton Key, List Keys, LineEdit Value, Control Row) entry) + { + rows.Remove(entry); + lines.RemoveChild(entry.Row); + entry.Row.QueueFree(); + changed?.Invoke(); + } + + private void OnPicked(long index) + { + if (!suppress) + changed?.Invoke(); + } + + private void OnTyped(string text) + { + if (!suppress) + changed?.Invoke(); + } + } +} diff --git a/src/Schemas/ScalarFieldEditor.cs b/src/Schemas/ScalarFieldEditor.cs new file mode 100644 index 0000000..390b582 --- /dev/null +++ b/src/Schemas/ScalarFieldEditor.cs @@ -0,0 +1,28 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Mods.Schemas; + using System; + using System.Text.Json.Nodes; + using Godot; + + /// A data editor form field with a single value: a plus the JSON conversion. + public class ScalarFieldEditor : ISchemaFieldEditor + { + private readonly SchemaFieldControl control; + + /// Adds the row for to . + public ScalarFieldEditor(Container parent, FieldSchema field, Action changed) + { + control = new SchemaFieldControl(parent, field, changed); + } + + /// + public FieldSchema Field => control.Field; + + /// + public void Load(JsonNode node) => control.Value = SchemaJson.ToText(node); + + /// + public JsonNode Save() => SchemaJson.ToNode(control.Field, control.Value); + } +} diff --git a/src/Schemas/SchemaFieldControl.cs b/src/Schemas/SchemaFieldControl.cs new file mode 100644 index 0000000..98280c5 --- /dev/null +++ b/src/Schemas/SchemaFieldControl.cs @@ -0,0 +1,202 @@ +namespace Cthangover.Tools.Schemas +{ + using Cthangover.Mods.Schemas; + using System; + using System.Collections.Generic; + using System.Linq; + using Cthangover.Mods.Interface.Theming; + using Cthangover.Tools.Common; + using Cthangover.Tools.EntityEditor; + using Godot; + + /// + /// The form control of one scalar , chosen by its type, as a + /// "label | editor" row. Everything reads and writes a text value, the way the DSL and + /// most JSON fields hold it, so the palette and the data editor window share these rows: + /// + /// bool — a check box ("1" or empty); + /// choice, ref, who — a drop-down of the options, of the entities of + /// the kind (by name), of player/party/characters; + /// ref_list — a comma list with a drop-down that appends an entity; + /// picture — a path with the picture grid of the editors; + /// anything else — a text box (monospace for formulas, conditions and JSON). + /// + /// A current value the options do not know is kept and shown first, so opening and + /// saving never loses data the schema did not foresee. + /// + public class SchemaFieldControl + { + private readonly Action changed; + private readonly LineEdit text; + private readonly OptionButton picker; + private readonly CheckBox check; + private readonly List pickerValues = new(); + private bool suppress; + + /// Adds the row for to ; runs on every edit. + public SchemaFieldControl(Container parent, FieldSchema field, Action changed) + { + Field = field; + this.changed = changed; + var label = string.IsNullOrEmpty(field.Name) ? field.Key : field.Name; + var tip = string.IsNullOrEmpty(field.Description) ? null : field.Description; + switch (Type) + { + case "bool": + check = new CheckBox(); + check.Toggled += OnToggled; + Editor = ToolUi.Field(parent, label, check, tip); + break; + case "choice": + case "ref": + case "who": + picker = new OptionButton { Name = "Picker_" + field.Key }; + picker.ItemSelected += OnPicked; + Editor = ToolUi.Field(parent, label, picker, tip); + break; + case "ref_list": + case "picture": + var row = new HBoxContainer(); + text = ToolUi.Text(field.Default ?? string.Empty, OnTyped); + text.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + row.AddChild(text); + if (Type == "picture") + { + var pick = UiKit.IconButton("…", ToolUi.Tr("tools/entity_editor/picture_pick_tip")); + pick.Pressed += () => OpenPicture(row); + row.AddChild(pick); + } + else + { + picker = new OptionButton { TooltipText = ToolUi.Tr("tools/schema/add_ref") }; + picker.ItemSelected += OnAppend; + row.AddChild(picker); + } + Editor = ToolUi.Field(parent, label, row, tip); + break; + default: + text = ToolUi.Text(field.Default ?? string.Empty, OnTyped); + text.Name = "Text_" + field.Key; + if (Type is "formula" or "condition" or "json") + text.AddThemeFontOverride("font", ToolUi.Mono()); + Editor = ToolUi.Field(parent, label, text, tip); + break; + } + FillPicker(null); + } + + /// The described field. + public FieldSchema Field { get; } + + /// The editor control (the row's right part). + public Control Editor { get; } + + /// The field type in lowercase (text when the schema names none). + public string Type => (Field.Type ?? "text").ToLowerInvariant(); + + /// The current value as text; empty when not given. + public string Value + { + get + { + if (check != null) + return check.ButtonPressed ? "1" : string.Empty; + if (text != null) + return text.Text.Trim(); + return picker != null && picker.Selected >= 0 && picker.Selected < pickerValues.Count ? pickerValues[picker.Selected] : string.Empty; + } + set + { + suppress = true; + if (check != null) + check.ButtonPressed = value is "1" or "true" or "True" or "yes"; + else if (text != null) + text.Text = value ?? string.Empty; + else + FillPicker(value ?? string.Empty); + suppress = false; + } + } + + private void FillPicker(string current) + { + if (picker == null) + return; + picker.Clear(); + pickerValues.Clear(); + var appendOnly = Type == "ref_list"; + Add(string.Empty, appendOnly ? ToolUi.Tr("tools/schema/add_ref") : "—"); + if (!appendOnly && !string.IsNullOrEmpty(current) && !Options().Contains(current, StringComparer.OrdinalIgnoreCase)) + Add(current, current); + foreach (var option in Options()) + Add(option, Type == "choice" ? option : GameEntityIndex.Label(KindOfOptions, option)); + var index = appendOnly ? 0 : Math.Max(0, pickerValues.FindIndex(v => string.Equals(v, current, StringComparison.OrdinalIgnoreCase))); + picker.Select(index); + } + + private void Add(string value, string label) + { + pickerValues.Add(value); + picker.AddItem(label); + } + + private string KindOfOptions => Type == "who" ? "character" : Field.Kind; + + private IEnumerable Options() + { + switch (Type) + { + case "choice": + return Field.Options ?? new List(); + case "who": + return new[] { "player", "party" }.Concat(GameEntityIndex.Of("character").Keys); + default: + return GameEntityIndex.Of(Field.Kind).Keys; + } + } + + private void OnTyped(string value) + { + if (!suppress) + changed?.Invoke(); + } + + private void OnToggled(bool on) + { + if (!suppress) + changed?.Invoke(); + } + + private void OnPicked(long index) + { + if (!suppress) + changed?.Invoke(); + } + + private void OnAppend(long index) + { + if (suppress || index <= 0 || index >= pickerValues.Count) + return; + var items = text.Text.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList(); + if (!items.Contains(pickerValues[(int)index])) + items.Add(pickerValues[(int)index]); + text.Text = string.Join(",", items); + picker.Select(0); + changed?.Invoke(); + } + + private void OpenPicture(Node host) + { + var dialog = new PicturePickerDialog(Field.Group ?? "items", text.Text); + dialog.Picked += OnPicturePicked; + host.AddChild(dialog); + dialog.PopupCentered(); + } + + private void OnPicturePicked(string id) + { + text.Text = id; + changed?.Invoke(); + } + } +} diff --git a/src/Schemas/SchemaFieldEditors.cs b/src/Schemas/SchemaFieldEditors.cs new file mode 100644 index 0000000..d2912ac --- /dev/null +++ b/src/Schemas/SchemaFieldEditors.cs @@ -0,0 +1,32 @@ +namespace Cthangover.Tools.Schemas +{ + using System; + using Cthangover.Mods.Schemas; + using Godot; + + /// + /// Picks the editor of a schema field by its type: a card list for list and + /// values, a key/value table for map, texts per language for locale, + /// a single control for everything else. Shared by the data editors (their "fields of + /// other mods" block) and the Data Editor window, so a field looks the same in both. + /// + public static class SchemaFieldEditors + { + /// Adds the editor of to ; runs on every edit. + public static ISchemaFieldEditor Create(Container parent, FieldSchema field, Action changed) + { + switch (field.Type?.ToLowerInvariant()) + { + case "list": + case "values": + return new ListFieldEditor(parent, field, changed); + case "map": + return new MapFieldEditor(parent, field, changed); + case "locale": + return new LocaleFieldEditor(parent, field, changed); + default: + return new ScalarFieldEditor(parent, field, changed); + } + } + } +}