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 <noreply@anthropic.com>
This commit is contained in:
cthangoverandClaude Opus 5.5 committed 2026-10-24 10:01:34 +02:00
1 parent 3ddb61d61e
commit 033192dcc9
16 files changed
+773 -2

No files matched your search

+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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 = + ステータス
+6
View File
@@ -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 = + 능력치
+6
View File
@@ -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 = + Стат
+6
View File
@@ -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ố
+6
View File
@@ -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 = + 属性
+1 -1
View File
@@ -11,7 +11,7 @@
"test_sources": [ "test_sources": [
"tests/*.cs" "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": [ "test_suites": [
{"id": "windows", "activate": ["--itest=tools_windows"]}, {"id": "windows", "activate": ["--itest=tools_windows"]},
{"id": "ingame_play", "activate": ["--itest=ingame_play"]}, {"id": "ingame_play", "activate": ["--itest=ingame_play"]},
+39 -1
View File
@@ -9,7 +9,9 @@ using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming; using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool; using Cthangover.Mods.Interface.Tool;
using Cthangover.Mods.Schemas;
using Cthangover.Tools.Common; using Cthangover.Tools.Common;
using Cthangover.Tools.Schemas;
using Cthangover.Tools.Services; using Cthangover.Tools.Services;
using Godot; using Godot;
@@ -30,6 +32,12 @@ namespace Cthangover.Tools.EntityEditor
/// editable, and every property the form does not know is shown as JSON under /// editable, and every property the form does not know is shown as JSON under
/// "other fields", edited there and written back as it is. /// "other fields", edited there and written back as it is.
/// <para/> /// <para/>
/// Fields other mods describe for the kind in their schemas (<c>schemas/*.json</c>: the
/// equipment mod adds <c>Equip</c> and <c>Traits</c> to items, the stats add <c>Archetype</c>
/// and <c>Stats</c> 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.
/// <para/>
/// Every change is undoable (Ctrl+Z / Ctrl+Y): one undo step per editing /// Every change is undoable (Ctrl+Z / Ctrl+Y): one undo step per editing
/// session of one entity, and one per add, duplicate or delete. /// session of one entity, and one per add, duplicate or delete.
/// </summary> /// </summary>
@@ -44,6 +52,7 @@ namespace Cthangover.Tools.EntityEditor
private readonly Dictionary<EntityField, TextureRect> previews = new(); private readonly Dictionary<EntityField, TextureRect> previews = new();
private readonly Dictionary<string, Texture2D> icons = new(); private readonly Dictionary<string, Texture2D> icons = new();
private readonly List<string> mods = new(); private readonly List<string> mods = new();
private readonly List<ISchemaFieldEditor> schemaEditors = new();
private OptionButton modPicker; private OptionButton modPicker;
private FilteredList list; private FilteredList list;
@@ -144,6 +153,7 @@ namespace Cthangover.Tools.EntityEditor
idEdit.AddThemeFontOverride("font", ToolUi.Mono()); idEdit.AddThemeFontOverride("font", ToolUi.Mono());
foreach (var field in fields) foreach (var field in fields)
BuildField(field); BuildField(field);
BuildSchemaFields();
form.AddChild(ToolUi.Section("tools/entity_editor/section_other")); form.AddChild(ToolUi.Section("tools/entity_editor/section_other"));
form.AddChild(ToolUi.Hint(ToolUi.Tr("tools/entity_editor/other_hint"))); 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) private void BuildPicture(EntityField field)
{ {
var row = new HBoxContainer(); var row = new HBoxContainer();
@@ -257,6 +281,8 @@ namespace Cthangover.Tools.EntityEditor
languages = LocaleFileService.Languages(mod); languages = LocaleFileService.Languages(mod);
foreach (var field in fields.Where(f => f.Kind == EntityFieldKind.LocaleText)) foreach (var field in fields.Where(f => f.Kind == EntityFieldKind.LocaleText))
BuildTextRows(field); BuildTextRows(field);
foreach (var locale in schemaEditors.OfType<LocaleFieldEditor>())
locale.Open(document, mod, languages);
MarkClean(); MarkClean();
selected = null; selected = null;
RefreshList(); RefreshList();
@@ -337,6 +363,8 @@ namespace Cthangover.Tools.EntityEditor
idEdit.Text = record.Id; idEdit.Text = record.Id;
foreach (var field in fields) foreach (var field in fields)
ShowField(field, record.Data); 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); otherEdit.Text = OtherJson(record.Data);
suppress = false; suppress = false;
} }
@@ -451,6 +479,15 @@ namespace Cthangover.Tools.EntityEditor
data["Id"] = idEdit.Text.Trim(); data["Id"] = idEdit.Text.Trim();
foreach (var field in fields) foreach (var field in fields)
WriteField(field, data); 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(); SetDirty();
RefreshList(); RefreshList();
} }
@@ -524,7 +561,8 @@ namespace Cthangover.Tools.EntityEditor
return EntityDocument.Serialize(other).TrimEnd(); 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() private void OnOtherChanged()
{ {
+18
View File
@@ -0,0 +1,18 @@
namespace Cthangover.Tools.Schemas
{
using Cthangover.Mods.Schemas;
using System.Text.Json.Nodes;
/// <summary>One field's editor in the data editor form: shows the field's JSON and gives it back edited.</summary>
public interface ISchemaFieldEditor
{
/// <summary>The described field.</summary>
FieldSchema Field { get; }
/// <summary>Shows <paramref name="node"/> (null: the field is absent).</summary>
void Load(JsonNode node);
/// <summary>The edited value, or null to remove the key.</summary>
JsonNode Save();
}
}
+173
View File
@@ -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;
/// <summary>
/// Edits a <c>list</c> field — an array of objects described by <see cref="FieldSchema.Of"/>
/// (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.
/// <para/>
/// The <c>values</c> type is the same editor over the engine's older list of
/// <c>"Key=Value"</c> strings (a battle action's <c>Values</c>): known keys become fields,
/// entries the schema does not know are kept as they were.
/// </summary>
public class ListFieldEditor : ISchemaFieldEditor
{
private readonly FieldSchema field;
private readonly Action changed;
private readonly VBoxContainer cards;
private readonly List<List<SchemaFieldControl>> rows = new();
private readonly List<string> unknownValues = new();
private bool suppress;
/// <inheritdoc />
public FieldSchema Field => field;
/// <summary>Adds the editor for <paramref name="field"/> to <paramref name="parent"/>.</summary>
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);
/// <summary>Shows the array <paramref name="node"/> (null or not an array: empty).</summary>
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<JsonObject>())
AddCard(item);
}
suppress = false;
}
/// <summary>The edited array, or null when it has no entries (the key is then removed).</summary>
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<SchemaFieldControl>();
foreach (var sub in field.Of ?? new List<FieldSchema>())
{
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<SchemaFieldControl> 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();
}
}
}
}
+110
View File
@@ -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;
/// <summary>
/// Edits a <c>locale</c> 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 <see cref="EntityDocument"/> and are written by the window's Save.
/// </summary>
public class LocaleFieldEditor : ISchemaFieldEditor
{
private readonly FieldSchema field;
private readonly Action changed;
private readonly LineEdit keyEdit;
private readonly VBoxContainer texts;
private readonly Dictionary<string, LineEdit> edits = new();
private EntityDocument document;
private string modId;
private bool suppress;
/// <inheritdoc />
public FieldSchema Field => field;
/// <summary>Adds the editor for <paramref name="field"/> to <paramref name="parent"/>.</summary>
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);
}
/// <summary>Builds one text row per language of <paramref name="mod"/>.</summary>
public void Open(EntityDocument doc, string mod, IEnumerable<string> 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;
}
}
/// <summary>Shows the key in <paramref name="node"/> and its texts.</summary>
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;
}
/// <summary>The key for the JSON (null when empty); texts are put into the document.</summary>
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();
}
}
}
+128
View File
@@ -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;
/// <summary>
/// Edits a <c>map</c> field — an object whose keys are ids of <see cref="FieldSchema.Kind"/>
/// and whose values are numbers or formulas (a character's own <c>Stats</c>, 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.
/// </summary>
public class MapFieldEditor : ISchemaFieldEditor
{
private readonly FieldSchema field;
private readonly Action changed;
private readonly VBoxContainer lines;
private readonly List<(OptionButton Key, List<string> Keys, LineEdit Value, Control Row)> rows = new();
private bool suppress;
/// <inheritdoc />
public FieldSchema Field => field;
/// <summary>Adds the editor for <paramref name="field"/> to <paramref name="parent"/>.</summary>
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);
}
/// <summary>Shows the object <paramref name="node"/>.</summary>
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;
}
/// <summary>The edited object, or null when empty.</summary>
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> { 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<string> 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();
}
}
}
+28
View File
@@ -0,0 +1,28 @@
namespace Cthangover.Tools.Schemas
{
using Cthangover.Mods.Schemas;
using System;
using System.Text.Json.Nodes;
using Godot;
/// <summary>A data editor form field with a single value: a <see cref="SchemaFieldControl"/> plus the JSON conversion.</summary>
public class ScalarFieldEditor : ISchemaFieldEditor
{
private readonly SchemaFieldControl control;
/// <summary>Adds the row for <paramref name="field"/> to <paramref name="parent"/>.</summary>
public ScalarFieldEditor(Container parent, FieldSchema field, Action changed)
{
control = new SchemaFieldControl(parent, field, changed);
}
/// <inheritdoc />
public FieldSchema Field => control.Field;
/// <inheritdoc />
public void Load(JsonNode node) => control.Value = SchemaJson.ToText(node);
/// <inheritdoc />
public JsonNode Save() => SchemaJson.ToNode(control.Field, control.Value);
}
}
+202
View File
@@ -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;
/// <summary>
/// The form control of one scalar <see cref="FieldSchema"/>, 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:
/// <list type="bullet">
/// <item><c>bool</c> — a check box ("1" or empty);</item>
/// <item><c>choice</c>, <c>ref</c>, <c>who</c> — a drop-down of the options, of the entities of
/// the kind (by name), of player/party/characters;</item>
/// <item><c>ref_list</c> — a comma list with a drop-down that appends an entity;</item>
/// <item><c>picture</c> — a path with the picture grid of the editors;</item>
/// <item>anything else — a text box (monospace for formulas, conditions and JSON).</item>
/// </list>
/// 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.
/// </summary>
public class SchemaFieldControl
{
private readonly Action changed;
private readonly LineEdit text;
private readonly OptionButton picker;
private readonly CheckBox check;
private readonly List<string> pickerValues = new();
private bool suppress;
/// <summary>Adds the row for <paramref name="field"/> to <paramref name="parent"/>; <paramref name="changed"/> runs on every edit.</summary>
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);
}
/// <summary>The described field.</summary>
public FieldSchema Field { get; }
/// <summary>The editor control (the row's right part).</summary>
public Control Editor { get; }
/// <summary>The field type in lowercase (<c>text</c> when the schema names none).</summary>
public string Type => (Field.Type ?? "text").ToLowerInvariant();
/// <summary>The current value as text; empty when not given.</summary>
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<string> Options()
{
switch (Type)
{
case "choice":
return Field.Options ?? new List<string>();
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();
}
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace Cthangover.Tools.Schemas
{
using System;
using Cthangover.Mods.Schemas;
using Godot;
/// <summary>
/// Picks the editor of a schema field by its type: a card list for <c>list</c> and
/// <c>values</c>, a key/value table for <c>map</c>, texts per language for <c>locale</c>,
/// 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.
/// </summary>
public static class SchemaFieldEditors
{
/// <summary>Adds the editor of <paramref name="field"/> to <paramref name="parent"/>; <paramref name="changed"/> runs on every edit.</summary>
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);
}
}
}
}