feat: use authored category names for mods

Store categories as authored strings. Trim and deduplicate without changing case, compare without case sensitivity, and keep all observed categories available to filters and editors.
This commit is contained in:
cthangover committed 2026-11-13 17:52:27 +01:00
1 parent 73478b7031
commit 1995aae77d
11 files changed
+406 -99

No files matched your search

+34 -14
View File
@@ -57,20 +57,20 @@ mods/catalog_export = Export to Catalog
mods/catalog_title = Export Catalog Entry
mods/catalog_download_url = Download URL
mods/catalog_category = Category
mods/category_story = Story
mods/category_characters = Characters
mods/category_gameplay = Gameplay
mods/category_visual = Visual
mods/category_audio = Audio
mods/category_ui = UI
mods/category_locale = Locale
mods/category_library = Library
mods/category_engine = Engine
mods/category_overhaul = Overhaul
mods/category_tools = Tools
mods/category_editor = Editor
mods/category_debug = Debug
mods/category_other = Other
mod_category/story = Story
mod_category/characters = Characters
mod_category/gameplay = Gameplay
mod_category/visual = Visual
mod_category/audio = Audio
mod_category/ui = UI
mod_category/locale = Locale
mod_category/library = Library
mod_category/engine = Engine
mod_category/overhaul = Overhaul
mod_category/tools = Tools
mod_category/editor = Editor
mod_category/debug = Debug
mod_category/other = Other
mods/catalog_tags = Tags (comma-separated)
mods/catalog_changelog = Changelog
mods/catalog_status = Status
@@ -269,3 +269,23 @@ tools/menu_title = Tools
# --- Development cover ---
ui/cover/in_development = In development
mod_categories/title = Categories
mod_categories/code = Category, e.g. RPG — Gear
mod_categories/add = Add
mod_categories/primary = Primary category
mod_categories/suggestions = Choose a category…
mod_categories/save = Save categories
mod_categories/saved = Categories saved to manifest.json.
mod_categories/development = Saved in the development mod before packaging.
mod_categories/readonly = Categories of installed packages and catalog entries are read-only. Edit the development folder.
mod_categories/pending = Add the typed category or clear the input before continuing.
mod_categories/hint_code = Enter any category name; spelling is preserved.
mod_categories/hint_suggestions = Choose a category from installed mods, catalogs or the base suggestions.
mod_categories/hint_primary = The primary category appears on the card and determines its group. It is saved first.
mod_categories/hint_add = Add the entered category.
mod_categories/hint_base = Include or remove this category.
mod_categories/hint_remove = Remove this custom category.
mod_categories/hint_filter = Show mods with this category:
+14 -14
View File
@@ -57,20 +57,20 @@ mods/catalog_export = Exportar al catálogo
mods/catalog_title = Exportar entrada del catálogo
mods/catalog_download_url = URL de descarga
mods/catalog_category = Categoría
mods/category_story = Historia
mods/category_characters = Personajes
mods/category_gameplay = Jugabilidad
mods/category_visual = Visual
mods/category_audio = Audio
mods/category_ui = Interfaz
mods/category_locale = Idioma
mods/category_library = Biblioteca
mods/category_engine = Motor
mods/category_overhaul = Conversión total
mods/category_tools = Herramientas
mods/category_editor = Editor
mods/category_debug = Depuración
mods/category_other = Otros
mod_category/story = Historia
mod_category/characters = Personajes
mod_category/gameplay = Jugabilidad
mod_category/visual = Visual
mod_category/audio = Audio
mod_category/ui = Interfaz
mod_category/locale = Idioma
mod_category/library = Biblioteca
mod_category/engine = Motor
mod_category/overhaul = Conversión total
mod_category/tools = Herramientas
mod_category/editor = Editor
mod_category/debug = Depuración
mod_category/other = Otros
mods/catalog_tags = Etiquetas (separadas por comas)
mods/catalog_changelog = Cambios
mods/catalog_status = Estado
+14 -14
View File
@@ -57,20 +57,20 @@ mods/catalog_export = カタログに書き出す
mods/catalog_title = カタログ項目を書き出す
mods/catalog_download_url = ダウンロード URL
mods/catalog_category = カテゴリー
mods/category_story = ストーリー
mods/category_characters = キャラクター
mods/category_gameplay = ゲームプレイ
mods/category_visual = ビジュアル
mods/category_audio = オーディオ
mods/category_ui = UI
mods/category_locale = 言語
mods/category_library = ライブラリ
mods/category_engine = エンジン
mods/category_overhaul = 大型改造
mods/category_tools = ツール
mods/category_editor = エディター
mods/category_debug = デバッグ
mods/category_other = その他
mod_category/story = ストーリー
mod_category/characters = キャラクター
mod_category/gameplay = ゲームプレイ
mod_category/visual = ビジュアル
mod_category/audio = オーディオ
mod_category/ui = UI
mod_category/locale = 言語
mod_category/library = ライブラリ
mod_category/engine = エンジン
mod_category/overhaul = 大型改造
mod_category/tools = ツール
mod_category/editor = エディター
mod_category/debug = デバッグ
mod_category/other = その他
mods/catalog_tags = タグ(カンマ区切り)
mods/catalog_changelog = 変更履歴
mods/catalog_status = 状態
+14 -14
View File
@@ -57,20 +57,20 @@ mods/catalog_export = 카탈로그로 내보내기
mods/catalog_title = 카탈로그 항목 내보내기
mods/catalog_download_url = 다운로드 URL
mods/catalog_category = 분류
mods/category_story = 스토리
mods/category_characters = 캐릭터
mods/category_gameplay = 게임플레이
mods/category_visual = 비주얼
mods/category_audio = 오디오
mods/category_ui = UI
mods/category_locale = 언어
mods/category_library = 라이브러리
mods/category_engine = 엔진
mods/category_overhaul = 대규모 개편
mods/category_tools = 도구
mods/category_editor = 에디터
mods/category_debug = 디버그
mods/category_other = 기타
mod_category/story = 스토리
mod_category/characters = 캐릭터
mod_category/gameplay = 게임플레이
mod_category/visual = 비주얼
mod_category/audio = 오디오
mod_category/ui = UI
mod_category/locale = 언어
mod_category/library = 라이브러리
mod_category/engine = 엔진
mod_category/overhaul = 대규모 개편
mod_category/tools = 도구
mod_category/editor = 에디터
mod_category/debug = 디버그
mod_category/other = 기타
mods/catalog_tags = 태그 (쉼표로 구분)
mods/catalog_changelog = 변경 기록
mods/catalog_status = 상태
+34 -14
View File
@@ -57,20 +57,20 @@ mods/catalog_export = Экспорт в каталог
mods/catalog_title = Экспорт записи каталога
mods/catalog_download_url = URL скачивания
mods/catalog_category = Категория
mods/category_story = Сюжет
mods/category_characters = Персонажи
mods/category_gameplay = Геймплей
mods/category_visual = Визуал
mods/category_audio = Аудио
mods/category_ui = Интерфейс
mods/category_locale = Перевод
mods/category_library = Библиотека
mods/category_engine = Движок
mods/category_overhaul = Тотальная конверсия
mods/category_tools = Инструменты
mods/category_editor = Редактор
mods/category_debug = Отладка
mods/category_other = Прочее
mod_category/story = Сюжет
mod_category/characters = Персонажи
mod_category/gameplay = Геймплей
mod_category/visual = Визуал
mod_category/audio = Аудио
mod_category/ui = Интерфейс
mod_category/locale = Перевод
mod_category/library = Библиотека
mod_category/engine = Движок
mod_category/overhaul = Тотальная конверсия
mod_category/tools = Инструменты
mod_category/editor = Редактор
mod_category/debug = Отладка
mod_category/other = Прочее
mods/catalog_tags = Теги (через запятую)
mods/catalog_changelog = Список изменений
mods/catalog_status = Статус
@@ -269,3 +269,23 @@ tools/menu_title = Инструменты
# --- Development cover ---
ui/cover/in_development = В разработке
mod_categories/title = Категории
mod_categories/code = Категория, например RPG — оружие
mod_categories/add = Добавить
mod_categories/primary = Основная категория
mod_categories/suggestions = Выбрать категорию…
mod_categories/save = Сохранить категории
mod_categories/saved = Категории сохранены в manifest.json.
mod_categories/development = Сохраняются в папке разработки мода до упаковки.
mod_categories/readonly = Категории установленных пакетов и записей каталога доступны для чтения. Правьте папку разработки.
mod_categories/pending = Добавьте набранную категорию или очистите поле перед продолжением.
mod_categories/hint_code = Введите любое название категории; написание сохраняется.
mod_categories/hint_suggestions = Выберите категорию из установленных модов, каталогов или базовых подсказок.
mod_categories/hint_primary = Основная категория показывается в карточке и определяет её группу. Сохраняется первой.
mod_categories/hint_add = Добавить введённую категорию.
mod_categories/hint_base = Добавить или убрать эту категорию.
mod_categories/hint_remove = Убрать эту свою категорию.
mod_categories/hint_filter = Показать моды с этой категорией:
+14 -14
View File
@@ -57,20 +57,20 @@ mods/catalog_export = Xuất sang danh mục
mods/catalog_title = Xuất mục danh mục
mods/catalog_download_url = URL tải xuống
mods/catalog_category = Thể loại
mods/category_story = Cốt truyện
mods/category_characters = Nhân vật
mods/category_gameplay = Lối chơi
mods/category_visual = Hình ảnh
mods/category_audio = Âm thanh
mods/category_ui = Giao diện
mods/category_locale = Ngôn ngữ
mods/category_library = Thư viện
mods/category_engine = Engine
mods/category_overhaul = Đại tu
mods/category_tools = Công cụ
mods/category_editor = Trình sửa
mods/category_debug = Gỡ lỗi
mods/category_other = Khác
mod_category/story = Cốt truyện
mod_category/characters = Nhân vật
mod_category/gameplay = Lối chơi
mod_category/visual = Hình ảnh
mod_category/audio = Âm thanh
mod_category/ui = Giao diện
mod_category/locale = Ngôn ngữ
mod_category/library = Thư viện
mod_category/engine = Bộ máy
mod_category/overhaul = Đại tu
mod_category/tools = Công cụ
mod_category/editor = Trình sửa
mod_category/debug = Gỡ lỗi
mod_category/other = Khác
mods/catalog_tags = Thẻ (cách nhau bằng dấu phẩy)
mods/catalog_changelog = Nhật ký thay đổi
mods/catalog_status = Trạng thái
+14 -14
View File
@@ -57,20 +57,20 @@ mods/catalog_export = 导出到目录
mods/catalog_title = 导出目录条目
mods/catalog_download_url = 下载 URL
mods/catalog_category = 分类
mods/category_story = 剧情
mods/category_characters = 角色
mods/category_gameplay = 玩法
mods/category_visual = 视觉
mods/category_audio = 音频
mods/category_ui = 界面
mods/category_locale = 语言
mods/category_library = 库
mods/category_engine = 引擎
mods/category_overhaul = 大型改动
mods/category_tools = 工具
mods/category_editor = 编辑器
mods/category_debug = 调试
mods/category_other = 其他
mod_category/story = 剧情
mod_category/characters = 角色
mod_category/gameplay = 玩法
mod_category/visual = 视觉
mod_category/audio = 音频
mod_category/ui = 界面
mod_category/locale = 语言
mod_category/library = 库
mod_category/engine = 引擎
mod_category/overhaul = 大型改动
mod_category/tools = 工具
mod_category/editor = 编辑器
mod_category/debug = 调试
mod_category/other = 其他
mods/catalog_tags = 标签(逗号分隔)
mods/catalog_changelog = 更新日志
mods/catalog_status = 状态
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.1.0",
"author": "ct",
"description": "The interface layer every game shares: the toolbar, the in-game menu, the save indicator, notifications and the common look of panels, buttons and lists.",
"category": "ui",
"categories": ["ui"],
"depends": [],
"sources": [
"src/**/*.cs"
@@ -0,0 +1,57 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/View/ModCategoryEditor.cs" id="1"]
[node name="ModCategoryEditor" type="Control"]
custom_minimum_size = Vector2(440, 260)
size_flags_horizontal = 3
script = ExtResource("1")
[node name="Layout" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 4
[node name="Caption" type="Label" parent="Layout"]
layout_mode = 2
text = "mod_categories/title"
[node name="Base" type="GridContainer" parent="Layout"]
layout_mode = 2
columns = 4
[node name="Custom" type="HFlowContainer" parent="Layout"]
layout_mode = 2
[node name="Input" type="HBoxContainer" parent="Layout"]
layout_mode = 2
[node name="Code" type="LineEdit" parent="Layout/Input"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "mod_categories/code"
[node name="Add" type="Button" parent="Layout/Input"]
layout_mode = 2
text = "mod_categories/add"
[node name="Suggestions" type="OptionButton" parent="Layout"]
layout_mode = 2
fit_to_longest_item = false
[node name="PrimaryRow" type="HBoxContainer" parent="Layout"]
layout_mode = 2
[node name="Caption" type="Label" parent="Layout/PrimaryRow"]
layout_mode = 2
text = "mod_categories/primary"
[node name="Primary" type="OptionButton" parent="Layout/PrimaryRow"]
layout_mode = 2
fit_to_longest_item = false
text_overrun_behavior = 3
size_flags_horizontal = 3
+165
View File
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Base.Lists;
using Godot;
namespace Cthangover.Mods.Interface.View
{
/// <summary>Shared ordered category editor. Base checkboxes, custom chips, code completion and primary choice edit the same list.</summary>
public partial class ModCategoryEditor : ModWidget
{
private readonly List<string> selected = new();
private readonly List<string> known = new();
private readonly Dictionary<string, CheckBox> checks = new(ModCategories.Comparer);
private LineEdit input;
private OptionButton suggestions;
private OptionButton primary;
private bool refreshing;
private bool editable = true;
public event Action Changed;
/// <summary>Snapshot in publishing order; choosing the primary moves it to the first position.</summary>
public List<string> Categories => new(selected);
public bool HasPendingInput => !string.IsNullOrWhiteSpace(input?.Text);
public static ModCategoryEditor Create(IEnumerable<string> categories)
{
var editor = TscnScenes.LoadAndBuild("scenes/components/mod_category_editor.tscn") as ModCategoryEditor
?? throw new InvalidOperationException("Category editor scene unavailable");
editor.SetCategories(categories);
return editor;
}
public static string ProjectRoot => GameEnvironment.IsRuntime ? GamePaths.Globalize("res://") : System.IO.Directory.GetCurrentDirectory();
public void SetCategories(IEnumerable<string> categories)
{
EnsureConstructed();
selected.Clear();
selected.AddRange(ModCategories.Effective(categories));
known.Clear();
known.AddRange(ModCategories.Normalize(ModCategoryNames.Known().Concat(selected)));
Refresh();
}
public void SetEditable(bool value)
{
editable = value;
EnsureConstructed();
Refresh();
}
protected override void Construct()
{
input = GetNode<LineEdit>("Layout/Input/Code");
suggestions = GetNode<OptionButton>("Layout/Suggestions");
primary = GetNode<OptionButton>("Layout/PrimaryRow/Primary");
input.TooltipText = Tr("mod_categories/hint_code");
suggestions.TooltipText = Tr("mod_categories/hint_suggestions");
primary.TooltipText = Tr("mod_categories/hint_primary");
GetNode<Button>("Layout/Input/Add").TooltipText = Tr("mod_categories/hint_add");
GetNode<Button>("Layout/Input/Add").Pressed += OnAdd;
input.TextSubmitted += OnSubmitted;
input.TextChanged += OnInput;
suggestions.ItemSelected += OnSuggestion;
primary.ItemSelected += OnPrimary;
foreach (var code in ModCategories.Base)
{
var check = new CheckBox { Name = code, Text = ModCategoryNames.Translate(code), TooltipText = Tr("mod_categories/hint_base") + " · " + code };
check.Toggled += value => OnToggle(code, value);
GetNode<GridContainer>("Layout/Base").AddChild(check);
checks.Add(code, check);
}
}
private void OnToggle(string code, bool value)
{
if (refreshing) return;
if (value && !selected.Contains(code, ModCategories.Comparer)) selected.Add(code);
else if (!value) selected.RemoveAll(category => ModCategories.Comparer.Equals(category, code));
Refresh();
Changed?.Invoke();
}
private void OnAdd()
{
var code = input.Text.Trim();
if (code.Length == 0) return;
if (!selected.Contains(code, ModCategories.Comparer)) selected.Add(code);
if (!known.Contains(code, ModCategories.Comparer)) known.Add(code);
input.Text = "";
Refresh();
Changed?.Invoke();
}
private void OnSubmitted(string text) => OnAdd();
private void OnInput(string text)
{
GetNode<Button>("Layout/Input/Add").Disabled = !editable || string.IsNullOrWhiteSpace(text);
RefreshSuggestions();
Changed?.Invoke();
}
private void RefreshSuggestions()
{
suggestions.Clear();
suggestions.AddItem(Tr("mod_categories/suggestions"));
foreach (var code in known.Where(code => !selected.Contains(code, ModCategories.Comparer) && code.Contains(input.Text.Trim(), StringComparison.OrdinalIgnoreCase)))
{
suggestions.AddItem(ModCategoryNames.Translate(code) + " · " + code);
suggestions.SetItemMetadata(suggestions.ItemCount - 1, code);
}
suggestions.Select(0);
suggestions.Disabled = !editable || suggestions.ItemCount == 1;
}
private void OnSuggestion(long index)
{
if (index > 0) input.Text = suggestions.GetItemMetadata((int)index).AsString();
}
private void OnPrimary(long index)
{
if (refreshing || index < 0 || index >= selected.Count) return;
var code = selected[(int)index];
selected.RemoveAt((int)index);
selected.Insert(0, code);
Refresh();
Changed?.Invoke();
}
private void Refresh()
{
refreshing = true;
foreach (var pair in checks)
{
pair.Value.SetPressedNoSignal(selected.Contains(pair.Key, ModCategories.Comparer));
pair.Value.Disabled = !editable;
}
var custom = GetNode<HFlowContainer>("Layout/Custom");
foreach (var child in custom.GetChildren()) { custom.RemoveChild(child); child.QueueFree(); }
foreach (var code in selected.Where(code => !ModCategories.Base.Contains(code, ModCategories.Comparer)))
{
var chip = new Button { Text = ModCategoryNames.Translate(code) + " ×", TooltipText = Tr("mod_categories/hint_remove") + " · " + code, Disabled = !editable, ClipText = true, TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis, CustomMinimumSize = new Vector2(160, 0) };
chip.SetMeta("category", code);
chip.Pressed += () => OnToggle(code, false);
custom.AddChild(chip);
}
primary.Clear();
foreach (var code in selected) primary.AddItem(ModCategoryNames.Translate(code));
if (selected.Count == 0) primary.AddItem(ModCategoryNames.Translate(ModCategories.Other));
primary.Select(0);
primary.Disabled = !editable || selected.Count < 2;
input.Editable = editable;
GetNode<Button>("Layout/Input/Add").Disabled = !editable || string.IsNullOrWhiteSpace(input.Text);
RefreshSuggestions();
CustomMinimumSize = new Vector2(Math.Max(440, GetNode<Control>("Layout").GetCombinedMinimumSize().X), GetNode<Control>("Layout").GetCombinedMinimumSize().Y);
refreshing = false;
}
}
}
+45
View File
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Mods.Interface.View
{
/// <summary>Shared presentation keeps the first authored spelling unless a translation is available.</summary>
public static class ModCategoryNames
{
public static string Translate(string category)
{
category = category?.Trim() ?? ModCategories.Other;
var baseName = ModCategories.Base.FirstOrDefault(name => ModCategories.Comparer.Equals(name, category));
var key = "mod_category/" + (baseName ?? category);
if (!GameEnvironment.IsRuntime) return category;
var translated = TranslationServer.Translate(key).ToString();
if (translated != key) return translated;
// Translation lookup may use a conventional lowercase key; the stored/display fallback stays authored.
var fallbackKey = "mod_category/" + category.ToLowerInvariant();
translated = TranslationServer.Translate(fallbackKey).ToString();
return translated != fallbackKey ? translated : category;
}
/// <summary>Alphabetical display-name order is stable while counts change; Other is always last.</summary>
public static List<string> Order(IEnumerable<string> categories)
=> ModCategories.Normalize(categories)
.OrderBy(category => ModCategories.Comparer.Equals(category, ModCategories.Other) ? 1 : 0)
.ThenBy(Translate, StringComparer.OrdinalIgnoreCase).ToList();
/// <summary>Completion includes installed mods, all cached catalogs and the translated base suggestions.</summary>
public static List<string> Known()
{
var categories = new List<string>();
foreach (var info in ModRegistry.Instance.AllDiscoveredMods.Values.OfType<ModInfo>())
categories.AddRange(ModCategories.Normalize(info.Manifest?.Categories));
foreach (var entry in CatalogService.CachedEntries ?? new List<CatalogExportEntry>())
categories.AddRange(ModCategories.Normalize(entry.Categories));
categories.AddRange(ModCategories.Base);
return Order(categories);
}
}
}