diff --git a/locale/ui_en.properties b/locale/ui_en.properties index 9d7e8f9..93128b6 100644 --- a/locale/ui_en.properties +++ b/locale/ui_en.properties @@ -305,3 +305,38 @@ tools/scene_editor/pick_scene = Pick a scene on the left or create a new one. tools/scene_editor/preview_hint = Pick a background to see it. tools/scene_editor/not_in_scene = (not in the scene) tools/scene_editor/new_line_text = A new scene. Replace this line with your text. +tools/entity_editor/hint = The list with a search on the left, the fields of the selected entry on the right · Ctrl+N new, Ctrl+D copy · Ctrl+Z/Y undo/redo · Ctrl+S save, the game sees the change at once +tools/entity_editor/section_list = Entries of the mod +tools/entity_editor/search = Search by name or id +tools/entity_editor/mod_tip = The mod whose entries are edited; new entries go into its file +tools/entity_editor/new = New +tools/entity_editor/new_tip = Add an entry to the chosen mod (Ctrl+N) +tools/entity_editor/duplicate = Copy +tools/entity_editor/duplicate_tip = Copy the selected entry under a new id (Ctrl+D) +tools/entity_editor/delete = Delete +tools/entity_editor/delete_tip = Remove the selected entry from the mod's file +tools/entity_editor/delete_title = Delete the entry? +tools/entity_editor/delete_text = The entry leaves the mod's file on save. References to it in scenarios and maps stay — check them. +tools/entity_editor/undo = Undo +tools/entity_editor/undo_tip = Undo the last change (Ctrl+Z) +tools/entity_editor/redo = Redo +tools/entity_editor/redo_tip = Redo what was undone (Ctrl+Y) +tools/entity_editor/no_entity = Pick an entry on the left or make one with "New". +tools/entity_editor/file = Mod {0} · file {1} +tools/entity_editor/id = Id +tools/entity_editor/id_tip = Unique id: scenarios, maps and other mods find the entry by it. A mod that depends on the owner and ships the same id replaces the entry +tools/entity_editor/locale_key_tip = Locale key; the texts below go into the mod's locale/*.properties +tools/entity_editor/text_in = text ({0}) +tools/entity_editor/picture_pick_tip = Pick a picture from the mod folders +tools/entity_editor/picture_pick_title = Pictures of the “{0}” group +tools/entity_editor/section_other = Other fields +tools/entity_editor/other_hint = Fields the form does not show (other mods' data, loot…) as JSON. Edits apply as soon as the JSON is valid. +tools/entity_editor/other_invalid = Other fields: invalid JSON — {0} +tools/entity_editor/other_applied = Other fields applied +tools/entity_editor/opened = {0} entries in mod {1} +tools/entity_editor/added = Added entry {0} to {1} +tools/entity_editor/deleted = Entry {0} deleted (save to write it) +tools/entity_editor/saved = Saved: {0} file(s), {1} text(s) in mod {2}; the game re-read its data +tools/entity_editor/broken_files = Files with broken JSON were not read: {0} +tools/entity_editor/problem_id = An entry in file {0} has an empty id +tools/entity_editor/problem_duplicate = Id {0} occurs more than once diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties index d27f4ae..eb7e9ad 100644 --- a/locale/ui_ru.properties +++ b/locale/ui_ru.properties @@ -305,3 +305,38 @@ tools/scene_editor/pick_scene = Выберите сцену слева или с tools/scene_editor/preview_hint = Выберите фон, чтобы посмотреть его. tools/scene_editor/not_in_scene = (не в сцене) tools/scene_editor/new_line_text = Новая сцена. Замените эту строку своим текстом. +tools/entity_editor/hint = Слева — список с поиском, справа — поля выбранной записи · Ctrl+N — новая, Ctrl+D — копия · Ctrl+Z/Y — отмена/повтор · Ctrl+S — сохранить, игра сразу видит изменения +tools/entity_editor/section_list = Записи мода +tools/entity_editor/search = Поиск по названию или id +tools/entity_editor/mod_tip = Мод, записи которого правятся; новые записи попадают в его файл +tools/entity_editor/new = Новый +tools/entity_editor/new_tip = Добавить запись в выбранный мод (Ctrl+N) +tools/entity_editor/duplicate = Копия +tools/entity_editor/duplicate_tip = Скопировать выбранную запись под новым id (Ctrl+D) +tools/entity_editor/delete = Удалить +tools/entity_editor/delete_tip = Удалить выбранную запись из файла мода +tools/entity_editor/delete_title = Удалить запись? +tools/entity_editor/delete_text = Запись исчезнет из файла мода после сохранения. Ссылки на неё в сценариях и картах останутся — проверьте их. +tools/entity_editor/undo = Отменить +tools/entity_editor/undo_tip = Отменить последнее изменение (Ctrl+Z) +tools/entity_editor/redo = Повторить +tools/entity_editor/redo_tip = Повторить отменённое (Ctrl+Y) +tools/entity_editor/no_entity = Выберите запись слева или создайте новую кнопкой «Новый». +tools/entity_editor/file = Мод {0} · файл {1} +tools/entity_editor/id = Id +tools/entity_editor/id_tip = Уникальный id: по нему запись находят сценарии, карты и другие моды. Мод, который зависит от владельца и даёт тот же id, заменяет запись +tools/entity_editor/locale_key_tip = Ключ локализации; тексты ниже пишутся в locale/*.properties мода +tools/entity_editor/text_in = текст ({0}) +tools/entity_editor/picture_pick_tip = Выбрать картинку из папки мода +tools/entity_editor/picture_pick_title = Картинки группы «{0}» +tools/entity_editor/section_other = Прочие поля +tools/entity_editor/other_hint = Поля, которых нет в форме (данные других модов, лут…), как JSON. Правки применяются, как только JSON корректен. +tools/entity_editor/other_invalid = Прочие поля: неверный JSON — {0} +tools/entity_editor/other_applied = Прочие поля применены +tools/entity_editor/opened = {0} записей в моде {1} +tools/entity_editor/added = Добавлена запись {0} в {1} +tools/entity_editor/deleted = Запись {0} удалена (сохраните, чтобы записать) +tools/entity_editor/saved = Сохранено: файлов {0}, текстов {1} в моде {2}; игра перечитала данные +tools/entity_editor/broken_files = Не прочитаны файлы с ошибкой JSON: {0} +tools/entity_editor/problem_id = У записи в файле {0} пустой id +tools/entity_editor/problem_duplicate = Id {0} встречается несколько раз diff --git a/manifest.json b/manifest.json index 955917f..cb86823 100644 --- a/manifest.json +++ b/manifest.json @@ -3,11 +3,14 @@ "name": "Editor Tools", "version": "1.0.0", "author": "ct", - "description": "Developer toolbox of ToolBox windows: scenario graph editor, scene editor (create and change the places of a novel: backgrounds, ambient sound, default scenario, the scene's scenarios), map editor with zones, scene builder with inspector, interactive and light editors, and the mod catalog export window.", + "description": "Developer toolbox of ToolBox windows: scenario graph editor, scene editor (create and change the places of a novel: backgrounds, ambient sound, default scenario, the scene's scenarios), map editor with zones, scene builder with inspector, interactive and light editors, the base of the data editors (items, characters: a searchable list and a form over JSON entities with texts in every language of the mod) and the mod catalog export window.", "category": "tools, editor", "sources": [ "src/**/*.cs" ], + "test_sources": [ + "tests/*.cs" + ], "depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"], "test_suites": [ {"id": "windows", "activate": ["--itest=tools_windows"]}, diff --git a/src/EntityEditor/EntityDocument.cs b/src/EntityEditor/EntityDocument.cs new file mode 100644 index 0000000..c0b8fb8 --- /dev/null +++ b/src/EntityEditor/EntityDocument.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Cthangover.Tools.EntityEditor +{ + /// + /// The entities of one kind ("Kind": "item", "character"…) in one + /// mod, as an editor changes them, free of any UI. + /// + /// Files are kept as JSON trees, not as typed DTOs: a form changes only the + /// properties it knows, and everything else — fields of other mods' C# + /// (heal, verb, Charset), loot tables, the property + /// order — survives a save untouched. A file is written only when its text + /// differs from what was loaded or last saved, so opening and saving does not + /// reformat files nobody changed. New entities go to , + /// created on the first save when the mod has no such file. + /// + /// Localized texts are part of the document too (): the + /// form shows a name in every language of the mod, and an undo step restores + /// texts and data together. Undo works on whole states, as in the map editor. + /// + public class EntityDocument + { + private const int HistoryLimit = 200; + + private static readonly JsonSerializerOptions WriteOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + private readonly Dictionary files = new(StringComparer.Ordinal); + private readonly Dictionary savedText = new(StringComparer.Ordinal); + private readonly List records = new(); + private readonly List undo = new(); + private readonly List redo = new(); + private Dictionary texts = new(StringComparer.Ordinal); + private Dictionary savedTexts = new(StringComparer.Ordinal); + + /// Entity kind of the document. + public string Kind { get; } + + /// File new entities are added to. + public string NewFile { get; } + + /// The entities in file order (files sorted by path). + public IReadOnlyList Records => records; + + /// True when there is a step to undo. + public bool CanUndo => undo.Count > 0; + + /// True when there is a step to redo. + public bool CanRedo => redo.Count > 0; + + private EntityDocument(string kind, string newFile) + { + Kind = kind; + NewFile = newFile; + } + + /// + /// Builds the document from the mod's JSON files (path, text). Files of + /// another kind, without a kind or with broken JSON are skipped; their + /// paths are returned in only when they declare + /// this kind but cannot be read, so the window can name them. + /// + public static EntityDocument Load(string kind, string newFile, IEnumerable<(string Path, string Text)> sources, out List skipped) + { + var document = new EntityDocument(kind, newFile); + skipped = new List(); + foreach (var (path, text) in sources.OrderBy(s => s.Path, StringComparer.Ordinal)) + { + JsonObject root; + try + { + root = JsonNode.Parse(text ?? string.Empty) as JsonObject; + } + catch (JsonException) + { + if (text != null && text.Contains($"\"{kind}\"")) + skipped.Add(path); + continue; + } + if (root == null || !string.Equals(root["Kind"]?.ToString(), kind, StringComparison.OrdinalIgnoreCase)) + continue; + if (root["Items"] is not JsonArray) + root["Items"] = new JsonArray(); + document.files[path] = root; + document.savedText[path] = Serialize(root); + } + document.Rebuild(); + return document; + } + + /// Paths of the document's files. + public IEnumerable Files => files.Keys; + + /// Adds an entity to (the file is created when missing) and returns it. + public EntityRecord Add(JsonObject entity) + { + if (!files.TryGetValue(NewFile, out var root)) + { + root = new JsonObject { ["Kind"] = Kind, ["Items"] = new JsonArray() }; + files[NewFile] = root; + } + ((JsonArray)root["Items"]).Add(entity); + Rebuild(); + return records.First(r => r.Data == entity); + } + + /// A copy of with id , placed right after it in its file. + public EntityRecord Duplicate(EntityRecord record, string newId) + { + var copy = (JsonObject)record.Data.DeepClone(); + copy["Id"] = newId; + var items = (JsonArray)files[record.File]["Items"]; + items.Insert(items.IndexOf(record.Data) + 1, copy); + Rebuild(); + return records.First(r => r.Data == copy); + } + + /// Removes an entity from its file (the file stays, maybe with no items). + public void Remove(EntityRecord record) + { + ((JsonArray)files[record.File]["Items"]).Remove(record.Data); + Rebuild(); + } + + /// when no entity has it, else wanted_2, wanted_3… + public string FreeId(string wanted) + { + var id = wanted; + for (var number = 2; records.Any(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); number++) + id = wanted + "_" + number; + return id; + } + + /// The record with this id, or null. + public EntityRecord Find(string id) => records.FirstOrDefault(r => r.Id == id); + + /// A localized text the document holds (see ), or null when it was never set or loaded. + public string GetText(string language, string key) + => texts.TryGetValue(TextKey(language, key), out var value) ? value : null; + + /// Remembers the text the mod's files have now (loading, not an edit). + public void LoadText(string language, string key, string value) + { + var textKey = TextKey(language, key); + texts[textKey] = value; + savedTexts[textKey] = value; + } + + /// Changes a localized text; it is written on save. + public void SetText(string language, string key, string value) => texts[TextKey(language, key)] = value ?? string.Empty; + + /// Remembers the current state as one undo step and forgets the redo branch. + public void BeginChange() + { + undo.Add(Snapshot()); + if (undo.Count > HistoryLimit) + undo.RemoveAt(0); + redo.Clear(); + } + + /// Steps back; false when there is nothing to undo. + public bool Undo() => Swap(undo, redo); + + /// Steps forward again; false when there is nothing to redo. + public bool Redo() => Swap(redo, undo); + + /// Files whose text changed since loading or the last . + public List<(string Path, string Text)> ChangedFiles() + => files.Select(f => (f.Key, Serialize(f.Value))) + .Where(f => !savedText.TryGetValue(f.Key, out var saved) || saved != f.Item2) + .ToList(); + + /// Texts changed since loading or the last : language, key, value. + public List<(string Language, string Key, string Value)> ChangedTexts() + => texts.Where(t => !savedTexts.TryGetValue(t.Key, out var saved) || saved != t.Value) + .Select(t => (t.Key.Substring(0, t.Key.IndexOf('\n')), t.Key.Substring(t.Key.IndexOf('\n') + 1), t.Value)) + .ToList(); + + /// The current files and texts are what is on disk now. + public void MarkSaved() + { + foreach (var (path, text) in ChangedFiles()) + savedText[path] = text; + savedTexts = new Dictionary(texts, StringComparer.Ordinal); + } + + /// Problems that would break the game's data, as locale keys with an argument; empty when it can be saved. + public List<(string Key, string Argument)> Validate() + { + var problems = new List<(string, string)>(); + foreach (var record in records.Where(r => string.IsNullOrWhiteSpace(r.Id))) + problems.Add(("tools/entity_editor/problem_id", record.File)); + foreach (var group in records.Where(r => !string.IsNullOrWhiteSpace(r.Id)).GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase)) + { + if (group.Count() > 1) + problems.Add(("tools/entity_editor/problem_duplicate", group.Key)); + } + return problems; + } + + /// A file's text as the document writes it: two-space indented, letters unescaped, LF line breaks (the writer would use CRLF on Windows), a final one. + public static string Serialize(JsonObject root) => root.ToJsonString(WriteOptions).Replace("\r\n", "\n") + "\n"; + + private static string TextKey(string language, string key) => language + "\n" + key; + + private void Rebuild() + { + records.Clear(); + foreach (var file in files.OrderBy(f => f.Key, StringComparer.Ordinal)) + { + foreach (var item in (JsonArray)file.Value["Items"]) + { + if (item is JsonObject entity) + records.Add(new EntityRecord(file.Key, entity)); + } + } + } + + private string Snapshot() + { + var state = new JsonObject + { + ["files"] = new JsonObject(files.Select(f => new KeyValuePair(f.Key, f.Value.DeepClone()))), + ["texts"] = new JsonObject(texts.Select(t => new KeyValuePair(t.Key, t.Value))) + }; + return state.ToJsonString(); + } + + private bool Swap(List from, List to) + { + if (from.Count == 0) + return false; + to.Add(Snapshot()); + var state = JsonNode.Parse(from[^1]).AsObject(); + from.RemoveAt(from.Count - 1); + files.Clear(); + foreach (var file in state["files"].AsObject()) + files[file.Key] = file.Value.DeepClone().AsObject(); + texts = state["texts"].AsObject().ToDictionary(t => t.Key, t => t.Value?.ToString() ?? string.Empty, StringComparer.Ordinal); + Rebuild(); + return true; + } + } +} diff --git a/src/EntityEditor/EntityEditorWindow.cs b/src/EntityEditor/EntityEditorWindow.cs new file mode 100644 index 0000000..d9e8129 --- /dev/null +++ b/src/EntityEditor/EntityEditorWindow.cs @@ -0,0 +1,701 @@ +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.Core.Utils; +using Cthangover.Mods.Interface.Theming; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Tools.Common; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Tools.EntityEditor +{ + /// + /// Base of the data editors (items, characters…): every entity of one kind in + /// one writable mod, a search list on the left and a form on the right built + /// from the editor's list. Layout and behaviour are + /// shared by all such editors, so they look and work alike; a concrete editor + /// only names its kind, its fields and how a new entity starts out. + /// + /// For a beginner the form is all there is: a name is typed in each language + /// of the mod (the locale key is made from the id), a picture is picked from a + /// grid, lists are check boxes. Save writes the mod's data and locale files and + /// makes the running game re-read them (entities, then texts), so the change is + /// visible at once. For a professional nothing is hidden: the locale key stays + /// editable, and every property the form does not know is shown as JSON under + /// "other fields", edited there and written back as it is. + /// + /// Every change is undoable (Ctrl+Z / Ctrl+Y): one undo step per editing + /// session of one entity, and one per add, duplicate or delete. + /// + public abstract partial class EntityEditorWindow : ToolWindow + { + private readonly string kind; + private readonly string folder; + private readonly List fields; + private readonly Dictionary controls = new(); + private readonly Dictionary keyEdits = new(); + private readonly Dictionary> textEdits = new(); + private readonly Dictionary previews = new(); + private readonly Dictionary icons = new(); + private readonly List mods = new(); + + private OptionButton modPicker; + private FilteredList list; + private VBoxContainer form; + private Label emptyHint; + private Label fileLabel; + private LineEdit idEdit; + private CodeEdit otherEdit; + private EntityDocument document; + private EntityRecord selected; + private List languages = new(); + private string modId; + private bool suppress; + private bool editing; + + /// Builds the window for entities of ; new ones go to <folder>/<mod>.json. + protected EntityEditorWindow(string titleKey, string entityKind, string entityFolder, List entityFields) : base(titleKey) + { + kind = entityKind; + folder = entityFolder; + fields = entityFields; + CthangoverTheme.Apply(this); + BuildUi(); + SetHint(ToolUi.Tr("tools/entity_editor/hint")); + FillMods(); + } + + /// A new entity's JSON: the kind's required fields with starting values. + protected abstract JsonObject CreateEntity(string id); + + /// Id a new entity starts from (numbered when taken), e.g. <mod>/new_item. + protected abstract string NewIdBase(string mod); + + /// The edited mod, or null. + public string ModId => modId; + + /// The selected entity's id, or null. + public string SelectedId => selected?.Id; + + /// The edited entities (null before a mod is opened). + public EntityDocument Document => document; + + // ── layout ───────────────────────────────────────────────── + + private void BuildUi() + { + var outer = CreateFillContainer(); + AddChild(outer); + outer.AddChild(BuildToolbar()); + + var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + outer.AddChild(main); + + var listColumn = new VBoxContainer { SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + listColumn.AddChild(ToolUi.Section("tools/entity_editor/section_list")); + list = new FilteredList(320f, "tools/entity_editor/search"); + list.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + list.Chosen += OnChosen; + listColumn.AddChild(list); + var listPanel = CreateSidebar(listColumn, 320); + main.AddChild(listPanel); + + 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); + emptyHint = ToolUi.Hint(ToolUi.Tr("tools/entity_editor/no_entity")); + column.AddChild(emptyHint); + form = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + form.AddThemeConstantOverride("separation", UiTokens.Space2); + column.AddChild(form); + BuildForm(); + } + + private HBoxContainer BuildToolbar() + { + 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")); + return toolbar; + } + + private void BuildForm() + { + fileLabel = ToolUi.Hint(string.Empty); + form.AddChild(fileLabel); + idEdit = ToolUi.Field(form, "tools/entity_editor/id", ToolUi.Text("", OnTextChanged), "tools/entity_editor/id_tip"); + idEdit.AddThemeFontOverride("font", ToolUi.Mono()); + foreach (var field in fields) + BuildField(field); + + form.AddChild(ToolUi.Section("tools/entity_editor/section_other")); + form.AddChild(ToolUi.Hint(ToolUi.Tr("tools/entity_editor/other_hint"))); + otherEdit = ToolUi.Code(); + otherEdit.CustomMinimumSize = new Vector2(0, 160); + otherEdit.SizeFlagsVertical = Control.SizeFlags.Fill; + otherEdit.TextChanged += OnOtherChanged; + form.AddChild(otherEdit); + } + + private void BuildField(EntityField field) + { + switch (field.Kind) + { + case EntityFieldKind.Text: + controls[field] = ToolUi.Field(form, field.LabelKey, ToolUi.Text("", OnTextChanged), field.TooltipKey); + break; + case EntityFieldKind.Integer: + controls[field] = ToolUi.Field(form, field.LabelKey, ToolUi.Number(-999999, 999999, 1, OnNumberChanged), field.TooltipKey); + break; + case EntityFieldKind.Choice: + var choice = ToolUi.Field(form, field.LabelKey, new OptionButton(), field.TooltipKey); + choice.ItemSelected += OnChoicePicked; + controls[field] = choice; + break; + case EntityFieldKind.Flags: + case EntityFieldKind.List: + controls[field] = ToolUi.Field(form, field.LabelKey, new HFlowContainer(), field.TooltipKey); + break; + case EntityFieldKind.Picture: + BuildPicture(field); + break; + case EntityFieldKind.LocaleText: + // The texts get their rows when a mod is opened: its languages decide them. + keyEdits[field] = ToolUi.Field(form, field.LabelKey, ToolUi.Text("", OnTextChanged), "tools/entity_editor/locale_key_tip"); + keyEdits[field].AddThemeFontOverride("font", ToolUi.Mono()); + var texts = new VBoxContainer(); + form.AddChild(texts); + controls[field] = texts; + textEdits[field] = new Dictionary(); + break; + } + } + + private void BuildPicture(EntityField field) + { + var row = new HBoxContainer(); + var path = ToolUi.Text(field.Group + "/…", OnPictureTyped); + path.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + row.AddChild(path); + var pick = UiKit.IconButton("…", ToolUi.Tr("tools/entity_editor/picture_pick_tip")); + pick.Pressed += () => OpenPicker(field); + row.AddChild(pick); + ToolUi.Field(form, field.LabelKey, row, field.TooltipKey); + controls[field] = path; + var preview = new TextureRect + { + CustomMinimumSize = new Vector2(96, 96), + ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, + StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered, + TextureFilter = CanvasItem.TextureFilterEnum.Nearest, + SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin + }; + var margin = new MarginContainer(); + margin.AddThemeConstantOverride("margin_left", ToolUi.LabelWidth + UiTokens.Space3); + margin.AddChild(preview); + form.AddChild(margin); + previews[field] = preview; + } + + // ── mods and the list ────────────────────────────────────── + + 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); + return; + } + // Start in the first mod that already has such entities: usually the game's own content. + var start = mods.FindIndex(m => LoadDocument(m).Records.Count > 0); + OpenMod(mods[Math.Max(0, start)]); + } + + private EntityDocument LoadDocument(string mod) + { + var sources = ModFileService.ListFiles(mod, string.Empty, ".json") + .Select(path => (path, ModFileService.ReadText(mod, path))); + var loaded = EntityDocument.Load(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); + return loaded; + } + + /// Opens the entities of a mod (asks first about unsaved changes). + public void OpenMod(string mod) + { + RunDiscardingChanges(() => DoOpenMod(mod), () => modPicker.Select(mods.IndexOf(modId))); + } + + private void DoOpenMod(string mod) + { + modId = mod; + modPicker.Select(mods.IndexOf(mod)); + document = LoadDocument(mod); + languages = LocaleFileService.Languages(mod); + foreach (var field in fields.Where(f => f.Kind == EntityFieldKind.LocaleText)) + BuildTextRows(field); + MarkClean(); + selected = null; + RefreshList(); + Select(document.Records.FirstOrDefault()); + SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/opened"), document.Records.Count, mod)); + } + + private void BuildTextRows(EntityField field) + { + var box = (VBoxContainer)controls[field]; + ClearChildren(box); + textEdits[field].Clear(); + foreach (var language in languages) + { + var label = string.Format(ToolUi.Tr("tools/entity_editor/text_in"), language); + var edit = ToolUi.Text("", OnTextChanged); + var row = new HBoxContainer(); + row.AddThemeConstantOverride("separation", UiTokens.Space3); + row.AddChild(new Label { Text = label, CustomMinimumSize = new Vector2(ToolUi.LabelWidth, 0), HorizontalAlignment = HorizontalAlignment.Right }); + edit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + row.AddChild(edit); + box.AddChild(row); + textEdits[field][language] = edit; + } + } + + private void RefreshList() + { + var first = fields.FirstOrDefault(f => f.Kind == EntityFieldKind.Picture); + var name = fields.FirstOrDefault(f => f.Kind == EntityFieldKind.LocaleText); + list.SetItems(document.Records.Select(r => (r.Id, RowText(r, name), first == null ? null : Icon(first.Group, Str(r.Data, first.Key))))); + list.Select(selected?.Id); + } + + private string RowText(EntityRecord record, EntityField nameField) + { + var key = nameField == null ? null : Str(record.Data, nameField.Key); + var text = key == null ? null : document.GetText(languages.FirstOrDefault() ?? "ru", key) ?? LocaleFileService.Read(modId, languages.FirstOrDefault() ?? "ru", key); + return string.IsNullOrWhiteSpace(text) ? record.Id : $"{text} · {record.Id}"; + } + + private Texture2D Icon(string group, string id) + { + if (string.IsNullOrEmpty(id)) + return null; + var cacheKey = group + "|" + id; + if (!icons.TryGetValue(cacheKey, out var texture)) + { + texture = ModResourceService.LoadGroupImage(group, id); + icons[cacheKey] = texture; + } + return texture; + } + + private void OnChosen(string id) => Select(document?.Find(id)); + + private void OnModPicked(long index) + { + if (index >= 0 && index < mods.Count && mods[(int)index] != modId) + OpenMod(mods[(int)index]); + } + + // ── form ─────────────────────────────────────────────────── + + /// Shows an entity in the form (null: none). + public void Select(EntityRecord record) + { + selected = record; + editing = false; + emptyHint.Visible = record == null; + form.Visible = record != null; + list.Select(record?.Id); + if (record == null) + return; + + suppress = true; + fileLabel.Text = string.Format(ToolUi.Tr("tools/entity_editor/file"), modId, record.File); + idEdit.Text = record.Id; + foreach (var field in fields) + ShowField(field, record.Data); + otherEdit.Text = OtherJson(record.Data); + suppress = false; + } + + private void ShowField(EntityField field, JsonObject data) + { + switch (field.Kind) + { + case EntityFieldKind.Text: + ((LineEdit)controls[field]).Text = Str(data, field.Key) ?? string.Empty; + break; + case EntityFieldKind.Integer: + ((SpinBox)controls[field]).Value = data[field.Key] is JsonValue v && v.TryGetValue(out int number) ? number : 0; + break; + case EntityFieldKind.Choice: + FillChoice((OptionButton)controls[field], field, Str(data, field.Key)); + break; + case EntityFieldKind.Flags: + FillChecks((HFlowContainer)controls[field], field, (Str(data, field.Key) ?? string.Empty).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); + break; + case EntityFieldKind.List: + FillChecks((HFlowContainer)controls[field], field, (data[field.Key] as JsonArray)?.Select(n => n?.ToString()).Where(s => s != null).ToArray() ?? Array.Empty()); + break; + case EntityFieldKind.Picture: + var path = Str(data, field.Key) ?? string.Empty; + ((LineEdit)controls[field]).Text = path; + previews[field].Texture = ModResourceService.LoadGroupImage(field.Group, path); + break; + case EntityFieldKind.LocaleText: + var key = Str(data, field.Key) ?? string.Empty; + keyEdits[field].Text = key; + foreach (var pair in textEdits[field]) + pair.Value.Text = TextOf(pair.Key, key); + break; + } + } + + private string TextOf(string language, string key) + { + if (string.IsNullOrEmpty(key)) + 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 static void FillChoice(OptionButton picker, EntityField field, string value) + { + var options = (field.Options?.Invoke() ?? Enumerable.Empty()).ToList(); + if (!string.IsNullOrEmpty(value) && !options.Contains(value)) + options.Insert(0, value); + picker.Clear(); + foreach (var option in options) + picker.AddItem(option); + picker.Select(options.IndexOf(value ?? string.Empty)); + } + + private void FillChecks(HFlowContainer box, EntityField field, string[] values) + { + ClearChildren(box); + var options = (field.Options?.Invoke() ?? Enumerable.Empty()).ToList(); + options.AddRange(values.Where(v => !options.Contains(v))); + foreach (var option in options) + { + var check = new CheckBox { Text = option, ButtonPressed = values.Contains(option) }; + check.Toggled += OnChecked; + box.AddChild(check); + } + } + + private void OnTextChanged(string text) => WriteForm(); + + private void OnNumberChanged(double value) => WriteForm(); + + private void OnChoicePicked(long index) => WriteForm(); + + private void OnChecked(bool on) => WriteForm(); + + private void OnPictureTyped(string text) + { + WriteForm(); + foreach (var pair in previews) + pair.Value.Texture = ModResourceService.LoadGroupImage(pair.Key.Group, ((LineEdit)controls[pair.Key]).Text); + } + + private void OpenPicker(EntityField field) + { + if (selected == null) + return; + var dialog = new PicturePickerDialog(field.Group, ((LineEdit)controls[field]).Text); + dialog.Picked += id => ((LineEdit)controls[field]).Text = id; + dialog.Picked += OnPictureTyped; + AddChild(dialog); + dialog.PopupCentered(); + } + + /// Copies the form into the selected entity; the first change of a session records an undo step. + 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 field in fields) + WriteField(field, data); + SetDirty(); + RefreshList(); + } + + private void WriteField(EntityField field, JsonObject data) + { + switch (field.Kind) + { + case EntityFieldKind.Text: + case EntityFieldKind.Picture: + SetOrRemove(data, field.Key, ((LineEdit)controls[field]).Text.Trim()); + break; + case EntityFieldKind.Integer: + var number = (int)((SpinBox)controls[field]).Value; + if (number != 0 || data.ContainsKey(field.Key)) + data[field.Key] = number; + break; + case EntityFieldKind.Choice: + var picker = (OptionButton)controls[field]; + if (picker.Selected >= 0) + data[field.Key] = picker.GetItemText(picker.Selected); + break; + case EntityFieldKind.Flags: + var flags = Checked((HFlowContainer)controls[field]); + if (flags.Count > 0 || data.ContainsKey(field.Key)) + data[field.Key] = flags.Count == 0 ? "None" : string.Join(",", flags); + break; + case EntityFieldKind.List: + var values = Checked((HFlowContainer)controls[field]); + if (values.Count > 0 || data.ContainsKey(field.Key)) + data[field.Key] = new JsonArray(values.Select(v => (JsonNode)v).ToArray()); + break; + case EntityFieldKind.LocaleText: + var key = keyEdits[field].Text.Trim(); + SetOrRemove(data, field.Key, key); + if (key.Length == 0) + break; + foreach (var pair in textEdits[field]) + document.SetText(pair.Key, key, pair.Value.Text); + break; + } + } + + private static List Checked(HFlowContainer box) + => box.GetChildren().OfType().Where(c => c.ButtonPressed).Select(c => c.Text).ToList(); + + /// Takes the children out of the tree at once (a search of the tree must not find them) and frees them. + private static void ClearChildren(Node box) + { + foreach (var child in box.GetChildren()) + { + box.RemoveChild(child); + child.QueueFree(); + } + } + + private static void SetOrRemove(JsonObject data, string key, string value) + { + if (value.Length > 0) + data[key] = value; + else + data.Remove(key); + } + + /// The properties no field shows, as an indented JSON object. + 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 bool IsFormKey(string key) => key == "Id" || fields.Any(f => f.Key == key); + + 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(); + SetStatus(ToolUi.Tr("tools/entity_editor/other_applied")); + } + + // ── commands ─────────────────────────────────────────────── + + private void AddNew() + { + if (document == null) + return; + document.BeginChange(); + var id = document.FreeId(NewIdBase(modId)); + var entity = CreateEntity(id); + foreach (var field in fields.Where(f => f.Kind == EntityFieldKind.LocaleText && f.KeyPattern != null)) + { + var key = string.Format(field.KeyPattern, id); + 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(); + RefreshList(); + Select(record); + 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(); + RefreshList(); + Select(record); + } + + 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(); + var id = selected.Id; + document.Remove(selected); + SetDirty(); + Select(null); + RefreshList(); + SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/deleted"), id)); + } + + private void Undo() => StepHistory(document != null && document.Undo()); + + private void Redo() => StepHistory(document != null && document.Redo()); + + private void StepHistory(bool stepped) + { + if (!stepped) + return; + // The document holds new JSON objects now: find the selection again by id. + var id = selected?.Id; + SetDirty(); + RefreshList(); + Select(document.Find(id) ?? document.Records.FirstOrDefault()); + } + + /// Ctrl+S. + protected override void OnSaveShortcut() => Save(); + + private void OnSavePressed() => Save(); + + /// + /// Writes the changed data and locale files and makes the game re-read + /// them: the entity cache is dropped (factories rebuild from the new + /// snapshot) and the current language's texts are reloaded. + /// + 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(); + icons.Clear(); + if (changedTexts.Count > 0) + LocaleFileService.ReloadGame(); + GameLogger.Log("TOOLS", $"entity editor: saved {changedFiles.Count} {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); + return true; + } + + /// Shortcuts: Ctrl+Z / Ctrl+Y undo and redo, Ctrl+N adds, Ctrl+D duplicates. + 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; + } + + private static string Str(JsonObject data, string key) => data[key]?.ToString(); + } +} diff --git a/src/EntityEditor/EntityField.cs b/src/EntityEditor/EntityField.cs new file mode 100644 index 0000000..b6c74f8 --- /dev/null +++ b/src/EntityEditor/EntityField.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace Cthangover.Tools.EntityEditor +{ + /// + /// One property of an entity as an form shows + /// it. A concrete editor lists its fields; properties no field names are + /// shown and edited as raw JSON ("other fields"), so the form never hides or + /// drops data a mod added. + /// + public class EntityField + { + /// JSON property name. + public string Key { get; init; } + + /// Locale key of the label. + public string LabelKey { get; init; } + + /// Locale key of the tooltip, or null. + public string TooltipKey { get; init; } + + /// How the value is edited. + public EntityFieldKind Kind { get; init; } + + /// Picture: the file group of the pictures (items, characters…). + public string Group { get; init; } + + /// Choice, flags, list: the values offered (read when the form is built). + public Func> Options { get; init; } + + /// Locale text: how a new entity's key is made from its id ({0} = id). + public string KeyPattern { get; init; } + } +} diff --git a/src/EntityEditor/EntityFieldKind.cs b/src/EntityEditor/EntityFieldKind.cs new file mode 100644 index 0000000..df5aaf3 --- /dev/null +++ b/src/EntityEditor/EntityFieldKind.cs @@ -0,0 +1,21 @@ +namespace Cthangover.Tools.EntityEditor +{ + /// How an is shown and stored. + public enum EntityFieldKind + { + /// One line of text. + Text, + /// A locale key with its text in every language of the mod. + LocaleText, + /// A whole number. + Integer, + /// A picture path in a file group, with a preview and a picker. + Picture, + /// One value of a list (values outside the list stay possible). + Choice, + /// Several names of a list written as "A,B" (an enum with flags). + Flags, + /// A JSON array of strings picked from a list. + List + } +} diff --git a/src/EntityEditor/EntityRecord.cs b/src/EntityEditor/EntityRecord.cs new file mode 100644 index 0000000..053f07b --- /dev/null +++ b/src/EntityEditor/EntityRecord.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Nodes; + +namespace Cthangover.Tools.EntityEditor +{ + /// One entity of an : its JSON object inside the Items array of its file. + public class EntityRecord + { + /// Mod-relative path of the file holding the entity. + public string File { get; } + + /// The entity itself; unknown properties are kept as they are. + public JsonObject Data { get; } + + /// The entity's Id (empty when missing). + public string Id => Data["Id"]?.ToString() ?? string.Empty; + + /// Wraps an entity of a file. + public EntityRecord(string file, JsonObject data) + { + File = file; + Data = data; + } + } +} diff --git a/src/EntityEditor/PicturePickerDialog.cs b/src/EntityEditor/PicturePickerDialog.cs new file mode 100644 index 0000000..106c5c9 --- /dev/null +++ b/src/EntityEditor/PicturePickerDialog.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Tools.Common; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Tools.EntityEditor +{ + /// + /// Grid of every picture of a file group with a search, for a picture field + /// of an entity form: a double click or OK picks one. The pictures are read + /// when the dialog opens, so a file just dropped into a mod folder shows up + /// (after the game re-reads its files: the editor's Save does that). + /// + public partial class PicturePickerDialog : AcceptDialog + { + private readonly FilteredList list; + + /// A picture was picked: its id in the group. + public event Action Picked; + + /// Builds the dialog for the pictures of . + public PicturePickerDialog(string group, string current) + { + Theme = ToolTheme.Get(); + Title = string.Format(ToolUi.Tr("tools/entity_editor/picture_pick_title"), group); + MinSize = new Vector2I(640, 520); + list = new FilteredList(420f); + list.UseIconGrid(64); + list.SetItems(ModResourceService.GetGroupImageIds(group) + .Select(id => (id, id.Substring(id.LastIndexOf('/') + 1), ModResourceService.LoadGroupImage(group, id)))); + list.Select(current); + list.Activated += OnActivated; + AddChild(list); + Confirmed += OnConfirmed; + CloseRequested += QueueFree; + Canceled += QueueFree; + } + + private void OnActivated(string id) + { + if (id == null) + return; + Picked?.Invoke(id); + Hide(); + QueueFree(); + } + + private void OnConfirmed() + { + if (list.SelectedId != null) + Picked?.Invoke(list.SelectedId); + QueueFree(); + } + } +} diff --git a/src/Services/LocaleFileService.cs b/src/Services/LocaleFileService.cs new file mode 100644 index 0000000..e650d43 --- /dev/null +++ b/src/Services/LocaleFileService.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Cthangover.Core.Localization; +using Godot; + +namespace Cthangover.Tools.Services +{ + /// + /// Texts of one mod's locale/*.properties for the editors that show a + /// localized name instead of its key. A language's files are + /// <lang>.properties and <anything>_<lang>.properties, + /// as LocaleLoader reads them. + /// + /// A write goes into the file of the language that already declares the key; + /// a new key goes into <lang>.properties, else the first non-scenario + /// file of the language, else a new locale/<lang>.properties. + /// Scenario string files (scenarios_*) never get entity texts. + /// + public static class LocaleFileService + { + private const string Dir = "locale"; + + /// Languages the mod has texts for, plus the game's current one; the current one first. + public static List Languages(string modId) + { + var current = LanguageCode.Normalize(TranslationServer.GetLocale()) ?? "ru"; + var languages = new List { current }; + foreach (var file in ModFileService.ListFiles(modId, Dir, ".properties")) + { + var language = LanguageOf(file); + if (language != null && !languages.Contains(language)) + languages.Add(language); + } + return languages; + } + + /// The mod's text of in , or null. + public static string Read(string modId, string language, string key) + { + string found = null; + foreach (var file in FilesOf(modId, language)) + found = PropertiesText.Get(ModFileService.ReadText(modId, file), key) ?? found; + return found; + } + + /// Sets the text of in ; false when the mod is not writable. + public static bool Write(string modId, string language, string key, string value) + { + var files = FilesOf(modId, language); + var target = files.FirstOrDefault(f => PropertiesText.Keys(ModFileService.ReadText(modId, f)).Contains(key)) + ?? files.FirstOrDefault(f => Path.GetFileName(f) == language + ".properties") + ?? files.FirstOrDefault(f => !Path.GetFileName(f).StartsWith("scenarios_", StringComparison.Ordinal)) + ?? $"{Dir}/{language}.properties"; + return ModFileService.WriteText(modId, target, PropertiesText.Set(ModFileService.ReadText(modId, target), key, value)); + } + + /// Reloads the game's texts of the current language, so a saved name shows at once. + public static void ReloadGame() => LocaleLoader.LoadLocale(TranslationServer.GetLocale()); + + private static List FilesOf(string modId, string language) + => ModFileService.ListFiles(modId, Dir, ".properties").Where(f => LanguageOf(f) == language && Path.GetDirectoryName(f)?.Replace('\\', '/') == Dir).ToList(); + + private static string LanguageOf(string file) + { + var name = Path.GetFileNameWithoutExtension(file); + var language = name.Substring(name.LastIndexOf('_') + 1); + return LanguageCode.Supported.Contains(language) ? language : null; + } + } +} diff --git a/src/Services/ModResourceService.cs b/src/Services/ModResourceService.cs index 0e4a7d3..211f3d6 100644 --- a/src/Services/ModResourceService.cs +++ b/src/Services/ModResourceService.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Cthangover.Core.Factories.Impls; using Cthangover.Core.Mods; using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Utils; using Godot; namespace Cthangover.Tools.Services @@ -121,6 +123,41 @@ namespace Cthangover.Tools.Services return result; } + /// + /// Pictures of a file group (items, characters, charsets…): + /// group-relative paths without extension, sorted — what an entity's picture + /// field holds (food/ration, village/slime). + /// + public static List GetGroupImageIds(string group) + { + var ids = new List(); + var files = Files.Collect(group); + if (files == null) + return ids; + var exts = GetExt(); + foreach (var key in files.Keys) + { + if (exts.Contains(Path.GetExtension(key).ToLower())) + ids.Add(Path.ChangeExtension(key, null)); + } + ids.Sort(StringComparer.OrdinalIgnoreCase); + return ids; + } + + /// + /// A picture of a file group by the id lists + /// (a group/ prefix is allowed, as data files often write it); null + /// without an error log when the group has no such picture, so a form can + /// show a half-typed path. + /// + public static Texture2D LoadGroupImage(string group, string id) + { + if (string.IsNullOrWhiteSpace(id)) + return null; + var path = id.StartsWith(group + "/", StringComparison.OrdinalIgnoreCase) ? id.Substring(group.Length + 1) : id; + return GetGroupImageIds(group).Contains(path, StringComparer.OrdinalIgnoreCase) ? TextureUtils.LoadFromModGroup(group, path) : null; + } + /// /// Loads a background texture by ID via . /// Returns null on any error (missing resource, load failure). diff --git a/src/Services/PropertiesText.cs b/src/Services/PropertiesText.cs new file mode 100644 index 0000000..f0d1ed7 --- /dev/null +++ b/src/Services/PropertiesText.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; + +namespace Cthangover.Tools.Services +{ + /// + /// Reading and changing one key of a .properties text the way + /// LocaleLoader reads it: key = value per line, # comments, + /// no escapes and no multi-line values (a line break in a value becomes a + /// space). Everything else in the text — comments, order, other keys, the + /// line endings — stays byte for byte, so an editor save shows up in a diff + /// as the one changed line. + /// + public static class PropertiesText + { + /// The value of , or null when the text does not declare it (the last line wins, as in the loader). + public static string Get(string text, string key) + { + string found = null; + foreach (var line in Lines(text)) + { + if (KeyOf(line) == key) + found = line.Substring(line.IndexOf('=') + 1).Trim(); + } + return found; + } + + /// All keys declared in the text. + public static HashSet Keys(string text) + { + var keys = new HashSet(StringComparer.Ordinal); + foreach (var line in Lines(text)) + { + var key = KeyOf(line); + if (key != null) + keys.Add(key); + } + return keys; + } + + /// + /// The text with set to : every + /// line of the key is rewritten in place, or a key = value line is + /// appended (after a line break the text may lack). Line endings follow the text. + /// + public static string Set(string text, string key, string value) + { + text ??= string.Empty; + value = (value ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim(); + var newline = text.Contains("\r\n") ? "\r\n" : "\n"; + var lines = text.Split('\n'); + var replaced = false; + for (var i = 0; i < lines.Length; i++) + { + var hasCr = lines[i].EndsWith("\r"); + if (KeyOf(hasCr ? lines[i][..^1] : lines[i]) != key) + continue; + lines[i] = $"{key} = {value}" + (hasCr ? "\r" : string.Empty); + replaced = true; + } + if (replaced) + return string.Join("\n", lines); + var head = text.Length == 0 || text.EndsWith("\n") ? text : text + newline; + return head + $"{key} = {value}" + newline; + } + + private static IEnumerable Lines(string text) => (text ?? string.Empty).Replace("\r\n", "\n").Split('\n'); + + private static string KeyOf(string rawLine) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') + return null; + var eq = line.IndexOf('='); + return eq <= 0 ? null : line.Substring(0, eq).Trim(); + } + } +} diff --git a/tests/EntityDocumentTests.cs b/tests/EntityDocumentTests.cs new file mode 100644 index 0000000..9a2debf --- /dev/null +++ b/tests/EntityDocumentTests.cs @@ -0,0 +1,119 @@ +namespace Cthangover.Tools.EntityEditor.Tests +{ + using System.Collections.Generic; + using System.Linq; + using System.Text.Json.Nodes; + using Cthangover.Core.Mods.Testing; + + /// The data editors' document: files of one kind, add/copy/delete, texts, undo and what gets written. + public class EntityDocumentTests + { + private const string Items = "{\n \"Kind\": \"item\",\n \"Items\": [\n {\n \"Id\": \"food/bread\",\n \"Name\": \"items/food/bread/name\",\n \"Cost\": 2,\n \"heal\": 5\n }\n ]\n}\n"; + + private static EntityDocument Load(out List skipped) => EntityDocument.Load("item", "items/my_mod.json", new[] + { + ("items/food/bread.json", Items), + ("characters/hero.json", "{\"Kind\": \"character\", \"Items\": [{\"Id\": \"hero\"}]}"), + ("items/broken.json", "{\"Kind\": \"item\", \"Items\": [ {"), + ("scenes/town.json", "{\"Name\": \"town\"}") + }, out skipped); + + [ModTest("entity_document/load_takes_only_its_kind")] + static void LoadTakesOnlyItsKind(ModTestContext ctx) + { + var document = Load(out var skipped); + ModAssert.Equal(1, document.Records.Count, "only the item file"); + ModAssert.Equal("food/bread", document.Records[0].Id, "its entity"); + ModAssert.Equal("items/food/bread.json", document.Records[0].File, "its file"); + ModAssert.Equal(1, skipped.Count, "the broken file of this kind is reported"); + ModAssert.Equal("items/broken.json", skipped[0], "by path"); + ModAssert.Equal(0, document.ChangedFiles().Count, "nothing to write after loading"); + } + + [ModTest("entity_document/unknown_fields_survive")] + static void UnknownFieldsSurvive(ModTestContext ctx) + { + var document = Load(out _); + document.Records[0].Data["Cost"] = 3; + + var changed = document.ChangedFiles(); + ModAssert.Equal(1, changed.Count, "the changed file"); + var root = JsonNode.Parse(changed[0].Text).AsObject(); + var item = root["Items"][0].AsObject(); + ModAssert.Equal("3", item["Cost"].ToString(), "the edit"); + ModAssert.Equal("5", item["heal"].ToString(), "another mod's field is kept"); + ModAssert.Equal("item", root["Kind"].ToString(), "the kind is kept"); + ModAssert.True(changed[0].Text.EndsWith("}\n"), "LF line break at the end"); + ModAssert.Equal(Items.Replace("\"Cost\": 2", "\"Cost\": 3"), changed[0].Text, "the rest of the file is byte for byte the same"); + } + + [ModTest("entity_document/add_copy_delete")] + static void AddCopyDelete(ModTestContext ctx) + { + var document = Load(out _); + var added = document.Add(new JsonObject { ["Id"] = document.FreeId("food/bread") }); + ModAssert.Equal("food/bread_2", added.Id, "a taken id gets a number"); + ModAssert.Equal("items/my_mod.json", added.File, "new entries go to the new file"); + + var copy = document.Duplicate(document.Find("food/bread"), document.FreeId("food/bread")); + ModAssert.Equal("food/bread_3", copy.Id, "the copy gets a free id"); + ModAssert.Equal("items/food/bread.json", copy.File, "and stays in the original's file"); + ModAssert.Equal("5", copy.Data["heal"].ToString(), "with all fields"); + ModAssert.Equal("food/bread", document.Records[0].Id, "the original first"); + ModAssert.Equal("food/bread_3", document.Records[1].Id, "the copy right after it"); + + document.Remove(document.Find("food/bread")); + ModAssert.Null(document.Find("food/bread"), "removed"); + var files = document.ChangedFiles().Select(f => f.Path).OrderBy(p => p).ToList(); + ModAssert.Equal(2, files.Count, "both files are written"); + ModAssert.Equal("items/food/bread.json", files[0], "the changed one"); + ModAssert.Equal("items/my_mod.json", files[1], "the new one"); + ModAssert.True(document.ChangedFiles().First(f => f.Path == "items/my_mod.json").Text.Contains("\"Kind\": \"item\""), "a new file declares the kind"); + } + + [ModTest("entity_document/undo_restores_data_and_texts")] + static void UndoRestoresDataAndTexts(ModTestContext ctx) + { + var document = Load(out _); + document.LoadText("ru", "items/food/bread/name", "Хлеб"); + + document.BeginChange(); + document.Records[0].Data["Cost"] = 9; + document.SetText("ru", "items/food/bread/name", "Каравай"); + document.BeginChange(); + document.Add(new JsonObject { ["Id"] = "food/cake" }); + + ModAssert.True(document.Undo(), "undo the add"); + ModAssert.Null(document.Find("food/cake"), "the new entry is gone"); + ModAssert.True(document.Undo(), "undo the edit"); + ModAssert.Equal("2", document.Find("food/bread").Data["Cost"].ToString(), "the cost is back"); + ModAssert.Equal("Хлеб", document.GetText("ru", "items/food/bread/name"), "the text is back"); + ModAssert.False(document.Undo(), "nothing more"); + ModAssert.Equal(0, document.ChangedFiles().Count + document.ChangedTexts().Count, "back to what is on disk"); + + ModAssert.True(document.Redo(), "redo the edit"); + var texts = document.ChangedTexts(); + ModAssert.Equal(1, texts.Count, "one changed text"); + ModAssert.Equal("ru", texts[0].Language, "its language"); + ModAssert.Equal("items/food/bread/name", texts[0].Key, "its key"); + ModAssert.Equal("Каравай", texts[0].Value, "its value"); + + document.MarkSaved(); + ModAssert.Equal(0, document.ChangedFiles().Count + document.ChangedTexts().Count, "nothing left after saving"); + } + + [ModTest("entity_document/validate_reports_ids")] + static void ValidateReportsIds(ModTestContext ctx) + { + var document = Load(out _); + ModAssert.Equal(0, document.Validate().Count, "a loaded mod is valid"); + document.Add(new JsonObject { ["Id"] = "FOOD/BREAD" }); + document.Add(new JsonObject { ["Name"] = "x" }); + + var problems = document.Validate(); + ModAssert.Equal(2, problems.Count, "an empty id and a duplicate"); + ModAssert.Equal("tools/entity_editor/problem_id", problems[0].Key, "empty id first"); + ModAssert.Equal("tools/entity_editor/problem_duplicate", problems[1].Key, "a duplicate ignores case"); + } + } +} diff --git a/tests/PropertiesTextTests.cs b/tests/PropertiesTextTests.cs new file mode 100644 index 0000000..54956e3 --- /dev/null +++ b/tests/PropertiesTextTests.cs @@ -0,0 +1,32 @@ +namespace Cthangover.Tools.Services.Tests +{ + using Cthangover.Core.Mods.Testing; + + /// Changing one key of a .properties text without touching the rest. + public class PropertiesTextTests + { + private const string Text = "# items\nitems/bread/name = Хлеб\nitems/bread/desc=Свежий\n\nitems/cake/name = Торт\n"; + + [ModTest("properties_text/get_reads_like_the_loader")] + static void GetReadsLikeTheLoader(ModTestContext ctx) + { + ModAssert.Equal("Хлеб", PropertiesText.Get(Text, "items/bread/name"), "spaces around '=' are trimmed"); + ModAssert.Equal("Свежий", PropertiesText.Get(Text, "items/bread/desc"), "no spaces work too"); + ModAssert.Null(PropertiesText.Get(Text, "items/pie/name"), "a missing key"); + ModAssert.Null(PropertiesText.Get(null, "x"), "no text"); + ModAssert.Equal("b", PropertiesText.Get("x = a\nx = b\n", "x"), "the last line wins"); + ModAssert.Equal(3, PropertiesText.Keys(Text).Count, "three keys, the comment is not one"); + } + + [ModTest("properties_text/set_changes_one_line")] + static void SetChangesOneLine(ModTestContext ctx) + { + ModAssert.Equal(Text.Replace("= Хлеб", "= Булка"), PropertiesText.Set(Text, "items/bread/name", "Булка"), "only that line changes"); + ModAssert.Equal(Text + "items/pie/name = Пирог\n", PropertiesText.Set(Text, "items/pie/name", "Пирог"), "a new key is appended"); + ModAssert.Equal("a = 1\nb = 2\n", PropertiesText.Set("a = 1", "b", "2"), "a missing final line break is added first"); + ModAssert.Equal("a = 1\r\nb = 2\r\n", PropertiesText.Set("a = 1\r\n", "b", "2"), "CRLF text stays CRLF"); + ModAssert.Equal("a = x\r\n", PropertiesText.Set("a = 1\r\n", "a", "x"), "a CRLF line is rewritten with its CR"); + ModAssert.Equal("a = one two\n", PropertiesText.Set("", "a", "one\ntwo"), "a line break in a value becomes a space"); + } + } +}