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);
+ }
+ }
+ }
+}