init mod
This commit is contained in:
commit
e8e0c5159d
46 files changed
+7923
No files matched your search
@@ -0,0 +1,221 @@
|
|||||||
|
# mod_manager — mod manager localization
|
||||||
|
|
||||||
|
mod_mod_manager_name = Mod Manager
|
||||||
|
mod_mod_manager_desc = Nexus-style mod manager: catalog browsing, category filters, install, update and remove.
|
||||||
|
|
||||||
|
mod_manager/menu_title = Mods & Collections
|
||||||
|
mod_manager/title = Mod Manager
|
||||||
|
mod_manager/back = Close
|
||||||
|
mod_manager/back_to_list = Back
|
||||||
|
mod_manager/search_placeholder = Search mods...
|
||||||
|
mod_manager/sort_name = By name
|
||||||
|
mod_manager/sort_author = By author
|
||||||
|
mod_manager/sort_status = By status
|
||||||
|
mod_manager/refresh = Refresh catalog
|
||||||
|
mod_manager/loading = Loading catalog...
|
||||||
|
mod_manager/results = Mods: {0}
|
||||||
|
|
||||||
|
mod_manager/filters = Filters
|
||||||
|
mod_manager/reset_filters = Reset
|
||||||
|
mod_manager/filter_status = Status
|
||||||
|
mod_manager/filter_categories = Categories
|
||||||
|
mod_manager/group_by_category = Group by category
|
||||||
|
|
||||||
|
mod_manager/status_installed = Installed
|
||||||
|
mod_manager/status_not_installed = Not installed
|
||||||
|
mod_manager/status_update = Update available
|
||||||
|
mod_manager/status_downgrade = Older version available
|
||||||
|
mod_manager/status_modified = Modified locally
|
||||||
|
mod_manager/status_disabled = Disabled
|
||||||
|
mod_manager/status_conflict = Catalog conflict
|
||||||
|
mod_manager/conflict_title = Warning
|
||||||
|
mod_manager/conflict_warning = The same mod with the same version and title is published by different catalogs with different content. One variant may be counterfeit: install only from a trusted source.
|
||||||
|
mod_manager/source_catalog = Catalog: {0}
|
||||||
|
mod_manager/source_local = Local mod
|
||||||
|
|
||||||
|
mod_manager/category_story = Story
|
||||||
|
mod_manager/category_characters = Characters
|
||||||
|
mod_manager/category_gameplay = Gameplay
|
||||||
|
mod_manager/category_visual = Visual
|
||||||
|
mod_manager/category_audio = Audio
|
||||||
|
mod_manager/category_ui = UI
|
||||||
|
mod_manager/category_locale = Locale
|
||||||
|
mod_manager/category_library = Library
|
||||||
|
mod_manager/category_engine = Engine
|
||||||
|
mod_manager/category_overhaul = Overhaul
|
||||||
|
mod_manager/category_tools = Tools
|
||||||
|
mod_manager/category_editor = Editor
|
||||||
|
mod_manager/category_debug = Debug
|
||||||
|
mod_manager/category_other = Other
|
||||||
|
|
||||||
|
mod_manager/action_install = Install
|
||||||
|
mod_manager/action_update = Update
|
||||||
|
mod_manager/action_downgrade = Downgrade
|
||||||
|
mod_manager/action_reinstall = Reinstall
|
||||||
|
mod_manager/action_delete = Delete
|
||||||
|
mod_manager/action_enable = Enable
|
||||||
|
mod_manager/action_disable = Disable
|
||||||
|
|
||||||
|
mod_manager/restart_required = Changes will take effect after restarting the game
|
||||||
|
mod_manager/progress_installing = Installing "{0}"...
|
||||||
|
mod_manager/install_failed = Install failed. Check your connection and try again.
|
||||||
|
mod_manager/delete_title = Delete mod
|
||||||
|
mod_manager/delete_text = Delete mod "{0}"? This is irreversible and takes effect after restart.
|
||||||
|
mod_manager/delete_confirm = Delete
|
||||||
|
mod_manager/delete_failed = Failed to delete the mod. See the log.
|
||||||
|
mod_manager/cancel = Cancel
|
||||||
|
|
||||||
|
mod_manager/installed_version = Installed: {0}
|
||||||
|
mod_manager/not_installed_hint = Not installed
|
||||||
|
mod_manager/latest = latest
|
||||||
|
mod_manager/installed_marker = installed
|
||||||
|
mod_manager/no_results = Nothing found
|
||||||
|
mod_manager/description = Description
|
||||||
|
mod_manager/depends = Depends on
|
||||||
|
mod_manager/used_by = Used by
|
||||||
|
mod_manager/conflicts = Conflicts
|
||||||
|
mod_manager/changelog = Changelog
|
||||||
|
mod_manager/version = Version:
|
||||||
|
mod_manager/self_hint = System mod: cannot be deleted or disabled
|
||||||
|
|
||||||
|
mod_manager/collections_menu_title = Collections
|
||||||
|
mod_manager/collections_title = Collections
|
||||||
|
mod_manager/collections_empty = No collections found
|
||||||
|
mod_manager/collection_result = Mods: {0}/{1}
|
||||||
|
mod_manager/collection_mechanics = Mechanics
|
||||||
|
mod_manager/collection_node = Mod
|
||||||
|
mod_manager/collection_plan = Install plan
|
||||||
|
mod_manager/collection_apply = Apply collection
|
||||||
|
mod_manager/collection_plan_ready = Everything is ready
|
||||||
|
mod_manager/collection_install_count = To install: {0}
|
||||||
|
mod_manager/collection_enable_count = To enable: {0}
|
||||||
|
mod_manager/collection_missing = Missing from the catalog: {0}
|
||||||
|
mod_manager/collection_mismatch = Version mismatch: {0}
|
||||||
|
mod_manager/collection_optional = optional
|
||||||
|
mod_manager/collection_externals = Dependencies
|
||||||
|
mod_manager/collection_external = dependency
|
||||||
|
mod_manager/collection_node_hint = Select a mod on the graph
|
||||||
|
mod_manager/collection_requires = Requires: {0}
|
||||||
|
mod_manager/collection_origin_builtin = built-in
|
||||||
|
mod_manager/collection_origin_user = local
|
||||||
|
mod_manager/collection_origin_catalog = catalog
|
||||||
|
mod_manager/collection_progress = Applying "{0}"...
|
||||||
|
mod_manager/collection_apply_failed = Failed to apply the collection. See the log.
|
||||||
|
mod_manager/collection_state_installed = Installed
|
||||||
|
mod_manager/collection_state_disabled = Disabled
|
||||||
|
mod_manager/collection_state_catalog = Available in catalog
|
||||||
|
mod_manager/collection_state_missing = Not found
|
||||||
|
mod_manager/collection_state_mismatch = Version mismatch
|
||||||
|
|
||||||
|
# Scheme editor (collections pyramid)
|
||||||
|
mod_manager/collection_add_mechanic = + Mechanic
|
||||||
|
mod_manager/collection_new_game = + Game
|
||||||
|
mod_manager/collection_new_game_name = New game
|
||||||
|
mod_manager/collection_new_game_desc = Assemble a game from mechanics: add blocks and mods.
|
||||||
|
mod_manager/collection_new_mechanic_name = New mechanic
|
||||||
|
mod_manager/collection_group_ready = ready
|
||||||
|
mod_manager/collection_group_empty = empty
|
||||||
|
mod_manager/collection_group_off = off
|
||||||
|
mod_manager/collection_links = Links
|
||||||
|
mod_manager/collection_problems = Problems
|
||||||
|
mod_manager/collection_tab_inspector = Inspector
|
||||||
|
mod_manager/collection_tab_problems = Check
|
||||||
|
mod_manager/collection_tab_plan = Plan
|
||||||
|
mod_manager/collection_plan_install = to install
|
||||||
|
mod_manager/collection_plan_enable = to enable
|
||||||
|
mod_manager/collection_save = Save
|
||||||
|
mod_manager/collection_reset = Reset
|
||||||
|
mod_manager/collection_json = JSON
|
||||||
|
mod_manager/collection_save_ok = Saved to user://collections.json
|
||||||
|
mod_manager/collection_no_changes = Nothing to save
|
||||||
|
mod_manager/collection_reset_confirm = Discard unsaved changes?
|
||||||
|
mod_manager/collection_rename_title = Name and description
|
||||||
|
mod_manager/collection_field_name = Name
|
||||||
|
mod_manager/collection_field_note = Description
|
||||||
|
mod_manager/collection_add_mod = Add mod
|
||||||
|
mod_manager/collection_empty_mechanic = Empty mechanic
|
||||||
|
mod_manager/collection_empty_mechanic_desc = Create a contour from scratch: add the name, description and mods manually.
|
||||||
|
mod_manager/collection_pick_mechanic = Add mechanic
|
||||||
|
mod_manager/collection_pick_replace = Replace mechanic
|
||||||
|
mod_manager/collection_choose = Add
|
||||||
|
mod_manager/collection_replace = Replace
|
||||||
|
mod_manager/collection_remove = Remove
|
||||||
|
mod_manager/collection_rename = Rename
|
||||||
|
mod_manager/collection_create = Create
|
||||||
|
mod_manager/collection_where = Add to
|
||||||
|
mod_manager/collection_new_extra = + new "Extra" mechanic
|
||||||
|
mod_manager/collection_extra_name = Extra
|
||||||
|
mod_manager/collection_extra_note = Manually added mods.
|
||||||
|
mod_manager/collection_search_mechanic = Search mechanics...
|
||||||
|
mod_manager/collection_search_mod = Search mods...
|
||||||
|
mod_manager/collection_problems_none = No problems - the assembly is consistent.
|
||||||
|
mod_manager/collection_problem_missing = Block "{0}" not found
|
||||||
|
mod_manager/collection_problem_cycle = Cycle: "{0}" -> "{1}"
|
||||||
|
mod_manager/collection_problem_role = Slot "{0}" is claimed twice
|
||||||
|
mod_manager/collection_problem_require = "{0}": requirement "{1}" is not met
|
||||||
|
mod_manager/collection_show = Show
|
||||||
|
mod_manager/collection_hint = Select a contour or a mod. Drag frames by the header, resize by the corner, move mods freely. Drag the empty canvas with the left button to pan.
|
||||||
|
mod_manager/collection_mechanic = mechanic
|
||||||
|
mod_manager/collection_group = group
|
||||||
|
mod_manager/collection_mods = mods
|
||||||
|
mod_manager/collection_in_group = In group
|
||||||
|
mod_manager/collection_remove_mod = Remove from mechanic
|
||||||
|
mod_manager/collection_turn_on = Turn on
|
||||||
|
mod_manager/collection_turn_off = Turn off
|
||||||
|
mod_manager/collection_unsaved = unsaved changes
|
||||||
|
|
||||||
|
# ─── Mods & collections hub ───
|
||||||
|
mod_manager/hub_tab_mods = Mods
|
||||||
|
mod_manager/hub_tab_collections = Collections
|
||||||
|
mod_manager/hub_back_to_menu = ← Menu
|
||||||
|
mod_manager/hub_back_to_gallery = ← Games
|
||||||
|
mod_manager/hub_in_one_collection = in {0} collection
|
||||||
|
mod_manager/hub_in_many_collections = in {0} collections
|
||||||
|
|
||||||
|
# Gallery of ready games
|
||||||
|
mod_manager/gallery_crumb = Ready-made games
|
||||||
|
mod_manager/gallery_title = Games
|
||||||
|
mod_manager/gallery_hint = ready-made mod sets: one button installs everything
|
||||||
|
mod_manager/gallery_search_placeholder = Search collections...
|
||||||
|
mod_manager/gallery_new_game = + Build your own game
|
||||||
|
mod_manager/gallery_new_game_blank = + Build your own game from scratch
|
||||||
|
mod_manager/gallery_empty_hint = Add a collections/ folder with JSON files to your mod — the collections will appear here.
|
||||||
|
mod_manager/gallery_edit_scheme = Edit scheme
|
||||||
|
mod_manager/gallery_ready = Ready to play
|
||||||
|
mod_manager/gallery_ready_done = Ready ✓
|
||||||
|
mod_manager/gallery_missing_n = Missing {0}
|
||||||
|
mod_manager/gallery_problems_n = Problems: {0}
|
||||||
|
mod_manager/gallery_enable_badge_n = Enable {0}
|
||||||
|
mod_manager/gallery_install_btn_n = Install ({0})
|
||||||
|
mod_manager/gallery_enable_btn_n = Enable ({0})
|
||||||
|
mod_manager/gallery_draft = Draft
|
||||||
|
mod_manager/gallery_continue = Continue building
|
||||||
|
|
||||||
|
# Bottom build bar (scheme editor)
|
||||||
|
mod_manager/build_caption = Build
|
||||||
|
mod_manager/build_stat_install = Install
|
||||||
|
mod_manager/build_stat_enable = Enable
|
||||||
|
mod_manager/build_stat_problems = Problems
|
||||||
|
mod_manager/build_hint_problems = Fix the problems to apply the collection
|
||||||
|
mod_manager/build_open_manager = Open mod manager
|
||||||
|
mod_manager/collection_crumb_scheme = Scheme
|
||||||
|
mod_manager/collection_find_in_catalog = Find in catalog
|
||||||
|
|
||||||
|
|
||||||
|
mod_manager/collection_duplicate = Duplicate
|
||||||
|
mod_manager/collection_revert = Revert to built-in
|
||||||
|
mod_manager/collection_saves = Saves: {0}
|
||||||
|
mod_manager/collection_next_launch = Changes apply the next time the game starts.
|
||||||
|
mod_manager/collection_json_apply = Apply
|
||||||
|
mod_manager/collection_json_invalid = Invalid JSON: {0}
|
||||||
|
mod_manager/collection_json_id_changed = The collection id cannot change — use Duplicate for a new game.
|
||||||
|
mod_manager/collection_copy_name = {0} (copy)
|
||||||
|
mod_manager/collection_copy_saves = The original game has saves ({0}). Copy them to the new game?
|
||||||
|
mod_manager/collection_copy_saves_ok = Copy
|
||||||
|
mod_manager/collection_revert_confirm = Discard your changes and restore the built-in version of the game? Saves stay.
|
||||||
|
mod_manager/collection_revert_done = Built-in version restored.
|
||||||
|
mod_manager/collection_delete = Delete collection
|
||||||
|
mod_manager/collection_delete_confirm = The collection «{0}» will be deleted. Its mods stay installed in the system, and its saves ({1}) stay on disk.
|
||||||
|
mod_manager/collection_delete_done = Collection deleted. Its mods stay in the system.
|
||||||
|
mod_manager/collection_nested_readonly = This block is nested deeper — open it on its own to change it.
|
||||||
|
mod_manager/collection_forked = Block “{0}” copied for this game — other games stay unchanged.
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# mod_manager — локализация менеджера модов
|
||||||
|
|
||||||
|
mod_mod_manager_name = Менеджер модов
|
||||||
|
mod_mod_manager_desc = Менеджер модов в стиле Nexus: каталог, фильтры по категориям, установка, обновление и удаление.
|
||||||
|
|
||||||
|
mod_manager/menu_title = Моды и коллекции
|
||||||
|
mod_manager/title = Менеджер модов
|
||||||
|
mod_manager/back = Закрыть
|
||||||
|
mod_manager/back_to_list = Назад
|
||||||
|
mod_manager/search_placeholder = Поиск модов...
|
||||||
|
mod_manager/sort_name = По имени
|
||||||
|
mod_manager/sort_author = По автору
|
||||||
|
mod_manager/sort_status = По статусу
|
||||||
|
mod_manager/refresh = Обновить каталог
|
||||||
|
mod_manager/loading = Загрузка каталога...
|
||||||
|
mod_manager/results = Модов: {0}
|
||||||
|
|
||||||
|
mod_manager/filters = Фильтры
|
||||||
|
mod_manager/reset_filters = Сбросить
|
||||||
|
mod_manager/filter_status = Статус
|
||||||
|
mod_manager/filter_categories = Категории
|
||||||
|
mod_manager/group_by_category = Группировать по категориям
|
||||||
|
|
||||||
|
mod_manager/status_installed = Установлен
|
||||||
|
mod_manager/status_not_installed = Не установлен
|
||||||
|
mod_manager/status_update = Есть обновление
|
||||||
|
mod_manager/status_downgrade = Доступна старая версия
|
||||||
|
mod_manager/status_modified = Изменён локально
|
||||||
|
mod_manager/status_disabled = Выключен
|
||||||
|
mod_manager/status_conflict = Конфликт каталогов
|
||||||
|
mod_manager/conflict_title = Предупреждение
|
||||||
|
mod_manager/conflict_warning = Один и тот же мод с одинаковой версией и названием опубликован в разных каталогах с разным содержимым. Один из вариантов может быть подделкой: устанавливайте только из доверенного источника.
|
||||||
|
mod_manager/source_catalog = Каталог: {0}
|
||||||
|
mod_manager/source_local = Локальный мод
|
||||||
|
|
||||||
|
mod_manager/category_story = Сюжет
|
||||||
|
mod_manager/category_characters = Персонажи
|
||||||
|
mod_manager/category_gameplay = Геймплей
|
||||||
|
mod_manager/category_visual = Визуал
|
||||||
|
mod_manager/category_audio = Аудио
|
||||||
|
mod_manager/category_ui = Интерфейс
|
||||||
|
mod_manager/category_locale = Перевод
|
||||||
|
mod_manager/category_library = Библиотека
|
||||||
|
mod_manager/category_engine = Движок
|
||||||
|
mod_manager/category_overhaul = Оверхаул
|
||||||
|
mod_manager/category_tools = Инструменты
|
||||||
|
mod_manager/category_editor = Редактор
|
||||||
|
mod_manager/category_debug = Отладка
|
||||||
|
mod_manager/category_other = Прочее
|
||||||
|
|
||||||
|
mod_manager/action_install = Установить
|
||||||
|
mod_manager/action_update = Обновить
|
||||||
|
mod_manager/action_downgrade = Понизить
|
||||||
|
mod_manager/action_reinstall = Переустановить
|
||||||
|
mod_manager/action_delete = Удалить
|
||||||
|
mod_manager/action_enable = Включить
|
||||||
|
mod_manager/action_disable = Выключить
|
||||||
|
|
||||||
|
mod_manager/restart_required = Изменения вступят в силу после перезапуска игры
|
||||||
|
mod_manager/progress_installing = Установка "{0}"...
|
||||||
|
mod_manager/install_failed = Не удалось установить мод. Проверьте подключение и повторите.
|
||||||
|
mod_manager/delete_title = Удаление мода
|
||||||
|
mod_manager/delete_text = Удалить мод "{0}"? Действие необратимо и вступит в силу после перезапуска.
|
||||||
|
mod_manager/delete_confirm = Удалить
|
||||||
|
mod_manager/delete_failed = Не удалось удалить мод. Смотрите лог.
|
||||||
|
mod_manager/cancel = Отмена
|
||||||
|
|
||||||
|
mod_manager/installed_version = Установлено: {0}
|
||||||
|
mod_manager/not_installed_hint = Не установлен
|
||||||
|
mod_manager/latest = последняя
|
||||||
|
mod_manager/installed_marker = установлена
|
||||||
|
mod_manager/no_results = Ничего не найдено
|
||||||
|
mod_manager/description = Описание
|
||||||
|
mod_manager/depends = Зависимости
|
||||||
|
mod_manager/used_by = Используется
|
||||||
|
mod_manager/conflicts = Конфликты
|
||||||
|
mod_manager/changelog = Изменения
|
||||||
|
mod_manager/version = Версия:
|
||||||
|
mod_manager/self_hint = Системный мод: удаление и выключение недоступны
|
||||||
|
|
||||||
|
mod_manager/collections_menu_title = Коллекции
|
||||||
|
mod_manager/collections_title = Коллекции
|
||||||
|
mod_manager/collections_empty = Коллекции не найдены
|
||||||
|
mod_manager/collection_result = Модов: {0}/{1}
|
||||||
|
mod_manager/collection_mechanics = Механики
|
||||||
|
mod_manager/collection_node = Мод
|
||||||
|
mod_manager/collection_plan = План установки
|
||||||
|
mod_manager/collection_apply = Применить коллекцию
|
||||||
|
mod_manager/collection_plan_ready = Всё готово
|
||||||
|
mod_manager/collection_install_count = Установить: {0}
|
||||||
|
mod_manager/collection_enable_count = Включить: {0}
|
||||||
|
mod_manager/collection_missing = Нет в каталоге: {0}
|
||||||
|
mod_manager/collection_mismatch = Несовпадение версий: {0}
|
||||||
|
mod_manager/collection_optional = необязательная
|
||||||
|
mod_manager/collection_externals = Зависимости
|
||||||
|
mod_manager/collection_external = зависимость
|
||||||
|
mod_manager/collection_node_hint = Выберите мод на графе
|
||||||
|
mod_manager/collection_requires = Требуется: {0}
|
||||||
|
mod_manager/collection_origin_builtin = встроенная
|
||||||
|
mod_manager/collection_origin_user = локальная
|
||||||
|
mod_manager/collection_origin_catalog = из каталога
|
||||||
|
mod_manager/collection_progress = Применение "{0}"...
|
||||||
|
mod_manager/collection_apply_failed = Не удалось применить коллекцию. Смотрите лог.
|
||||||
|
mod_manager/collection_state_installed = Установлен
|
||||||
|
mod_manager/collection_state_disabled = Выключен
|
||||||
|
mod_manager/collection_state_catalog = Доступен в каталоге
|
||||||
|
mod_manager/collection_state_missing = Не найден
|
||||||
|
mod_manager/collection_state_mismatch = Версия не подходит
|
||||||
|
|
||||||
|
# Схема коллекции (пирамида)
|
||||||
|
mod_manager/collection_add_mechanic = + Механика
|
||||||
|
mod_manager/collection_new_game = + Игра
|
||||||
|
mod_manager/collection_new_game_name = Новая игра
|
||||||
|
mod_manager/collection_new_game_desc = Соберите игру из механик: добавляйте блоки и моды.
|
||||||
|
mod_manager/collection_new_mechanic_name = Новая механика
|
||||||
|
mod_manager/collection_group_ready = готово
|
||||||
|
mod_manager/collection_group_empty = пусто
|
||||||
|
mod_manager/collection_group_off = выключена
|
||||||
|
mod_manager/collection_links = Связи
|
||||||
|
mod_manager/collection_problems = Проблемы
|
||||||
|
mod_manager/collection_tab_inspector = Инспектор
|
||||||
|
mod_manager/collection_tab_problems = Проверка
|
||||||
|
mod_manager/collection_tab_plan = План
|
||||||
|
mod_manager/collection_plan_install = установить
|
||||||
|
mod_manager/collection_plan_enable = включить
|
||||||
|
mod_manager/collection_save = Сохранить
|
||||||
|
mod_manager/collection_reset = Сбросить
|
||||||
|
mod_manager/collection_json = JSON
|
||||||
|
mod_manager/collection_save_ok = Сохранено в user://collections.json
|
||||||
|
mod_manager/collection_no_changes = Нет изменений для сохранения
|
||||||
|
mod_manager/collection_reset_confirm = Сбросить несохранённые правки?
|
||||||
|
mod_manager/collection_rename_title = Название и описание
|
||||||
|
mod_manager/collection_field_name = Название
|
||||||
|
mod_manager/collection_field_note = Описание
|
||||||
|
mod_manager/collection_add_mod = Добавить мод
|
||||||
|
mod_manager/collection_empty_mechanic = Пустая механика
|
||||||
|
mod_manager/collection_empty_mechanic_desc = Создать контур с нуля: название, описание и моды добавите вручную.
|
||||||
|
mod_manager/collection_pick_mechanic = Добавить механику
|
||||||
|
mod_manager/collection_pick_replace = Заменить механику
|
||||||
|
mod_manager/collection_choose = Добавить
|
||||||
|
mod_manager/collection_replace = Заменить
|
||||||
|
mod_manager/collection_remove = Убрать
|
||||||
|
mod_manager/collection_rename = Переименовать
|
||||||
|
mod_manager/collection_create = Создать
|
||||||
|
mod_manager/collection_where = Куда добавить
|
||||||
|
mod_manager/collection_new_extra = + новая механика «Дополнительно»
|
||||||
|
mod_manager/collection_extra_name = Дополнительно
|
||||||
|
mod_manager/collection_extra_note = Добавленные вручную моды.
|
||||||
|
mod_manager/collection_search_mechanic = Поиск механики...
|
||||||
|
mod_manager/collection_search_mod = Поиск мода...
|
||||||
|
mod_manager/collection_problems_none = Проблем нет — сборка согласована.
|
||||||
|
mod_manager/collection_problem_missing = Блок «{0}» не найден
|
||||||
|
mod_manager/collection_problem_cycle = Цикл: «{0}» → «{1}»
|
||||||
|
mod_manager/collection_problem_role = Слот «{0}» занят дважды
|
||||||
|
mod_manager/collection_problem_require = «{0}»: не выполнено требование «{1}»
|
||||||
|
mod_manager/collection_show = Показать
|
||||||
|
mod_manager/collection_hint = Выберите контур или мод. Контуры двигаются за заголовок, тянутся за угол, моды внутри двигаются свободно. Пустое поле схемы перемещается перетаскиванием ЛКМ.
|
||||||
|
mod_manager/collection_mechanic = механика
|
||||||
|
mod_manager/collection_group = группа
|
||||||
|
mod_manager/collection_mods = модов
|
||||||
|
mod_manager/collection_in_group = В механике
|
||||||
|
mod_manager/collection_remove_mod = Убрать из механики
|
||||||
|
mod_manager/collection_turn_on = Включить
|
||||||
|
mod_manager/collection_turn_off = Выключить
|
||||||
|
mod_manager/collection_unsaved = ● несохранённые правки
|
||||||
|
|
||||||
|
# ─── Хаб «Моды и коллекции» ───
|
||||||
|
mod_manager/hub_tab_mods = Моды
|
||||||
|
mod_manager/hub_tab_collections = Коллекции
|
||||||
|
mod_manager/hub_back_to_menu = ← Меню
|
||||||
|
mod_manager/hub_back_to_gallery = ← К играм
|
||||||
|
mod_manager/hub_in_one_collection = в {0} коллекции
|
||||||
|
mod_manager/hub_in_many_collections = в {0} коллекциях
|
||||||
|
|
||||||
|
# Галерея готовых игр
|
||||||
|
mod_manager/gallery_crumb = Готовые игры
|
||||||
|
mod_manager/gallery_title = Игры
|
||||||
|
mod_manager/gallery_hint = готовые наборы модов: одна кнопка — и всё установлено
|
||||||
|
mod_manager/gallery_search_placeholder = Поиск коллекций...
|
||||||
|
mod_manager/gallery_new_game = + Собрать свою игру
|
||||||
|
mod_manager/gallery_new_game_blank = + Собрать свою игру с нуля
|
||||||
|
mod_manager/gallery_empty_hint = Добавьте в свой мод папку collections/ с JSON-файлами — коллекции появятся здесь.
|
||||||
|
mod_manager/gallery_edit_scheme = Изменить схему
|
||||||
|
mod_manager/gallery_ready = Готова к игре
|
||||||
|
mod_manager/gallery_ready_done = Готова ✓
|
||||||
|
mod_manager/gallery_missing_n = Не хватает {0}
|
||||||
|
mod_manager/gallery_problems_n = Проблемы: {0}
|
||||||
|
mod_manager/gallery_enable_badge_n = Включить {0}
|
||||||
|
mod_manager/gallery_install_btn_n = Установить ({0})
|
||||||
|
mod_manager/gallery_enable_btn_n = Включить ({0})
|
||||||
|
mod_manager/gallery_draft = Черновик
|
||||||
|
mod_manager/gallery_continue = Продолжить сборку
|
||||||
|
|
||||||
|
# Нижняя панель сборки (редактор схемы)
|
||||||
|
mod_manager/build_caption = Сборка
|
||||||
|
mod_manager/build_stat_install = Установить
|
||||||
|
mod_manager/build_stat_enable = Включить
|
||||||
|
mod_manager/build_stat_problems = Проблемы
|
||||||
|
mod_manager/build_hint_problems = Устраните проблемы, чтобы применить коллекцию
|
||||||
|
mod_manager/build_open_manager = Открыть менеджер модов
|
||||||
|
mod_manager/collection_crumb_scheme = Схема
|
||||||
|
mod_manager/collection_find_in_catalog = Найти в каталоге
|
||||||
|
|
||||||
|
|
||||||
|
mod_manager/collection_duplicate = Дублировать
|
||||||
|
mod_manager/collection_revert = Вернуть встроенную
|
||||||
|
mod_manager/collection_saves = Сохранений: {0}
|
||||||
|
mod_manager/collection_next_launch = Изменения вступят в силу при следующем запуске игры.
|
||||||
|
mod_manager/collection_json_apply = Применить
|
||||||
|
mod_manager/collection_json_invalid = JSON не разобран: {0}
|
||||||
|
mod_manager/collection_json_id_changed = Id коллекции менять нельзя — для новой игры используйте «Дублировать».
|
||||||
|
mod_manager/collection_copy_name = {0} (копия)
|
||||||
|
mod_manager/collection_copy_saves = У исходной игры есть сохранения ({0}). Скопировать их в новую игру?
|
||||||
|
mod_manager/collection_copy_saves_ok = Скопировать
|
||||||
|
mod_manager/collection_revert_confirm = Удалить ваши изменения и вернуть встроенную версию игры? Сохранения останутся.
|
||||||
|
mod_manager/collection_revert_done = Возвращена встроенная версия.
|
||||||
|
mod_manager/collection_delete = Удалить коллекцию
|
||||||
|
mod_manager/collection_delete_confirm = Коллекция «{0}» будет удалена. Моды из неё останутся установленными в системе, сохранения ({1}) — на диске.
|
||||||
|
mod_manager/collection_delete_done = Коллекция удалена. Моды остались в системе.
|
||||||
|
mod_manager/collection_nested_readonly = Этот блок вложен глубже — откройте его отдельно, чтобы изменить.
|
||||||
|
mod_manager/collection_forked = Блок «{0}» скопирован для этой игры — другие игры не изменятся.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "mod_manager",
|
||||||
|
"name": "Mod Manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "ct",
|
||||||
|
"description": "Nexus-style mod manager: browse the catalog, filter by category and status, install, update and remove mods.",
|
||||||
|
"category": "tools, ui",
|
||||||
|
"sources": [
|
||||||
|
"src/**/*.cs"
|
||||||
|
],
|
||||||
|
"depends": ["interface>=1"],
|
||||||
|
"main_menu": [
|
||||||
|
{
|
||||||
|
"id": "mods_hub",
|
||||||
|
"scene_path": "scenes/mods_hub_screen.tscn",
|
||||||
|
"button": { "icon": "mod_manager", "locale": "mod_manager/menu_title", "index": 100 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="src/UI/ModCard.cs" id="1_card"]
|
||||||
|
|
||||||
|
[node name="ModCard" type="PanelContainer"]
|
||||||
|
custom_minimum_size = Vector2(236, 0)
|
||||||
|
size_flags_horizontal = 0
|
||||||
|
mouse_filter = 0
|
||||||
|
script = ExtResource("1_card")
|
||||||
|
|
||||||
|
[node name="VBox" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 0
|
||||||
|
|
||||||
|
[node name="Cover" type="Control" parent="VBox"]
|
||||||
|
custom_minimum_size = Vector2(0, 132)
|
||||||
|
layout_mode = 2
|
||||||
|
clip_contents = true
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="Placeholder" type="ColorRect" parent="VBox/Cover"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
color = Color(0.054902, 0.058824, 0.082353, 1)
|
||||||
|
|
||||||
|
[node name="Image" type="TextureRect" parent="VBox/Cover"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
expand_mode = 1
|
||||||
|
stretch_mode = 6
|
||||||
|
|
||||||
|
[node name="Body" type="MarginContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/margin_left = 10
|
||||||
|
theme_override_constants/margin_top = 8
|
||||||
|
theme_override_constants/margin_right = 10
|
||||||
|
theme_override_constants/margin_bottom = 10
|
||||||
|
|
||||||
|
[node name="Box" type="VBoxContainer" parent="VBox/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 5
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="VBox/Body/Box"]
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
clip_text = true
|
||||||
|
autowrap_mode = 3
|
||||||
|
max_lines_visible = 2
|
||||||
|
text_overrun_behavior = 3
|
||||||
|
|
||||||
|
[node name="Author" type="Label" parent="VBox/Body/Box"]
|
||||||
|
custom_minimum_size = Vector2(0, 16)
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
clip_text = true
|
||||||
|
text_overrun_behavior = 3
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
|
||||||
|
[node name="Source" type="Label" parent="VBox/Body/Box"]
|
||||||
|
custom_minimum_size = Vector2(0, 16)
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
clip_text = true
|
||||||
|
text_overrun_behavior = 3
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
|
||||||
|
[node name="MetaRow" type="HBoxContainer" parent="VBox/Body/Box"]
|
||||||
|
custom_minimum_size = Vector2(0, 28)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 6
|
||||||
|
|
||||||
|
[node name="Status" type="Label" parent="VBox/Body/Box/MetaRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 4
|
||||||
|
mouse_filter = 2
|
||||||
|
clip_text = true
|
||||||
|
text_overrun_behavior = 3
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
vertical_alignment = 1
|
||||||
|
|
||||||
|
[node name="VersionHost" type="Control" parent="VBox/Body/Box/MetaRow"]
|
||||||
|
custom_minimum_size = Vector2(102, 28)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 0
|
||||||
|
size_flags_vertical = 4
|
||||||
|
mouse_filter = 2
|
||||||
|
clip_contents = true
|
||||||
|
|
||||||
|
[node name="Versions" type="OptionButton" parent="VBox/Body/Box/MetaRow/VersionHost"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
|
||||||
|
[node name="ActionRow" type="HBoxContainer" parent="VBox/Body/Box"]
|
||||||
|
custom_minimum_size = Vector2(0, 32)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 6
|
||||||
|
|
||||||
|
[node name="ActionHost" type="Control" parent="VBox/Body/Box/ActionRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 4
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="Action" type="Button" parent="VBox/Body/Box/ActionRow/ActionHost"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
clip_text = true
|
||||||
|
text_overrun_behavior = 3
|
||||||
|
|
||||||
|
[node name="EnabledHost" type="Control" parent="VBox/Body/Box/ActionRow"]
|
||||||
|
custom_minimum_size = Vector2(40, 32)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 0
|
||||||
|
size_flags_vertical = 4
|
||||||
|
mouse_filter = 2
|
||||||
|
clip_contents = true
|
||||||
|
|
||||||
|
[node name="Enabled" type="CheckButton" parent="VBox/Body/Box/ActionRow/EnabledHost"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="src/UI/ModDetailView.cs" id="1_detail"]
|
||||||
|
|
||||||
|
[node name="ModDetailView" type="VBoxContainer"]
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
script = ExtResource("1_detail")
|
||||||
|
|
||||||
|
[node name="Scroll" type="ScrollContainer" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="Content" type="VBoxContainer" parent="Scroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 14
|
||||||
|
|
||||||
|
[node name="Hero" type="PanelContainer" parent="Scroll/Content"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="HeroMargin" type="MarginContainer" parent="Scroll/Content/Hero"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/margin_left = 14
|
||||||
|
theme_override_constants/margin_top = 14
|
||||||
|
theme_override_constants/margin_right = 14
|
||||||
|
theme_override_constants/margin_bottom = 14
|
||||||
|
|
||||||
|
[node name="HeroRow" type="HBoxContainer" parent="Scroll/Content/Hero/HeroMargin"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
|
||||||
|
[node name="Cover" type="Control" parent="Scroll/Content/Hero/HeroMargin/HeroRow"]
|
||||||
|
custom_minimum_size = Vector2(270, 180)
|
||||||
|
layout_mode = 2
|
||||||
|
clip_contents = true
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="Placeholder" type="ColorRect" parent="Scroll/Content/Hero/HeroMargin/HeroRow/Cover"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
color = Color(0.054902, 0.058824, 0.082353, 1)
|
||||||
|
|
||||||
|
[node name="Image" type="TextureRect" parent="Scroll/Content/Hero/HeroMargin/HeroRow/Cover"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
expand_mode = 1
|
||||||
|
stretch_mode = 6
|
||||||
|
|
||||||
|
[node name="Info" type="VBoxContainer" parent="Scroll/Content/Hero/HeroMargin/HeroRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 6
|
||||||
|
|
||||||
|
[node name="Back" type="Button" parent="Scroll/Content/Hero/HeroMargin/HeroRow/Info"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 0
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="Scroll/Content/Hero/HeroMargin/HeroRow/Info"]
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 3
|
||||||
|
theme_override_font_sizes/font_size = 22
|
||||||
|
|
||||||
|
[node name="Author" type="Label" parent="Scroll/Content/Hero/HeroMargin/HeroRow/Info"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Meta" type="Label" parent="Scroll/Content/Hero/HeroMargin/HeroRow/Info"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="VersionRow" type="HBoxContainer" parent="Scroll/Content/Hero/HeroMargin/HeroRow/Info"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="Caption" type="Label" parent="Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Versions" type="OptionButton" parent="Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 0
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Installed" type="Label" parent="Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Size" type="Label" parent="Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="ActionsRow" type="HBoxContainer" parent="Scroll/Content"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="ConflictBox" type="VBoxContainer" parent="Scroll/Content"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 4
|
||||||
|
|
||||||
|
[node name="Caption" type="Label" parent="Scroll/Content/ConflictBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
|
||||||
|
[node name="Text" type="Label" parent="Scroll/Content/ConflictBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="DescriptionBox" type="VBoxContainer" parent="Scroll/Content"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 4
|
||||||
|
|
||||||
|
[node name="Caption" type="Label" parent="Scroll/Content/DescriptionBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
|
||||||
|
[node name="Text" type="Label" parent="Scroll/Content/DescriptionBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="ChangelogBox" type="VBoxContainer" parent="Scroll/Content"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 4
|
||||||
|
|
||||||
|
[node name="Caption" type="Label" parent="Scroll/Content/ChangelogBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
|
||||||
|
[node name="Text" type="Label" parent="Scroll/Content/ChangelogBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="DependsBox" type="VBoxContainer" parent="Scroll/Content"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 4
|
||||||
|
|
||||||
|
[node name="Caption" type="Label" parent="Scroll/Content/DependsBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
|
||||||
|
[node name="Text" type="Label" parent="Scroll/Content/DependsBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="UsedByBox" type="VBoxContainer" parent="Scroll/Content"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 4
|
||||||
|
|
||||||
|
[node name="Caption" type="Label" parent="Scroll/Content/UsedByBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
|
||||||
|
[node name="Text" type="Label" parent="Scroll/Content/UsedByBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="ConflictsBox" type="VBoxContainer" parent="Scroll/Content"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 4
|
||||||
|
|
||||||
|
[node name="Caption" type="Label" parent="Scroll/Content/ConflictsBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
|
||||||
|
[node name="Text" type="Label" parent="Scroll/Content/ConflictsBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 3
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="src/UI/ModFilterPanel.cs" id="1_filter"]
|
||||||
|
|
||||||
|
[node name="ModFilterPanel" type="VBoxContainer"]
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
script = ExtResource("1_filter")
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 16
|
||||||
|
|
||||||
|
[node name="Reset" type="Button" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 0
|
||||||
|
|
||||||
|
[node name="Sep1" type="HSeparator" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="StatusCaption" type="Label" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
|
||||||
|
[node name="StatusList" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 2
|
||||||
|
|
||||||
|
[node name="Sep2" type="HSeparator" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="CategoryCaption" type="Label" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
|
||||||
|
[node name="CategoryList" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 2
|
||||||
|
|
||||||
|
[node name="Sep3" type="HSeparator" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="GroupToggle" type="CheckButton" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="src/UI/ModGridGroup.cs" id="1_group"]
|
||||||
|
|
||||||
|
[node name="ModGridGroup" type="VBoxContainer"]
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
script = ExtResource("1_group")
|
||||||
|
|
||||||
|
[node name="Header" type="Label" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
|
||||||
|
[node name="Flow" type="HFlowContainer" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/h_separation = 12
|
||||||
|
theme_override_constants/v_separation = 12
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="src/UI/ModGridView.cs" id="1_grid"]
|
||||||
|
|
||||||
|
[node name="ModGridView" type="ScrollContainer"]
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
script = ExtResource("1_grid")
|
||||||
|
|
||||||
|
[node name="Host" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 18
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="src/ModCollectionsScreen.cs" id="1_screen"]
|
||||||
|
[ext_resource type="Script" path="src/UI/CollectionSchemeCanvas.cs" id="2_canvas"]
|
||||||
|
|
||||||
|
[node name="ModCollectionsScreen" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 0
|
||||||
|
script = ExtResource("1_screen")
|
||||||
|
|
||||||
|
[node name="Background" type="ColorRect" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
color = Color(0.054902, 0.058824, 0.082353, 1)
|
||||||
|
|
||||||
|
[node name="Margin" type="MarginContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/margin_left = 16
|
||||||
|
theme_override_constants/margin_top = 12
|
||||||
|
theme_override_constants/margin_right = 16
|
||||||
|
theme_override_constants/margin_bottom = 12
|
||||||
|
|
||||||
|
[node name="Root" type="VBoxContainer" parent="Margin"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="Header" type="HBoxContainer" parent="Margin/Root"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="Close" type="Button" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_override_font_sizes/font_size = 24
|
||||||
|
uppercase = true
|
||||||
|
|
||||||
|
[node name="GameSelect" type="OptionButton" parent="Margin/Root/Header"]
|
||||||
|
custom_minimum_size = Vector2(220, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="AddMechanic" type="Button" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="NewGame" type="Button" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Spacer" type="Control" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="EdgesToggle" type="CheckBox" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
button_pressed = true
|
||||||
|
|
||||||
|
[node name="Problems" type="Button" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Plan" type="Button" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="ModsCount" type="Label" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Dirty" type="Label" parent="Margin/Root/Header"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Json" type="Button" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Reset" type="Button" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Save" type="Button" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Refresh" type="Button" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Crumbs" type="HBoxContainer" parent="Margin/Root"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="Body" type="HSplitContainer" parent="Margin/Root"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="CanvasScroll" type="ScrollContainer" parent="Margin/Root/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_scroll_mode = 1
|
||||||
|
vertical_scroll_mode = 1
|
||||||
|
|
||||||
|
[node name="SchemeCanvas" type="Control" parent="Margin/Root/Body/CanvasScroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
script = ExtResource("2_canvas")
|
||||||
|
|
||||||
|
[node name="Details" type="PanelContainer" parent="Margin/Root/Body"]
|
||||||
|
custom_minimum_size = Vector2(380, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="DetailScroll" type="ScrollContainer" parent="Margin/Root/Body/Details"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="Detail" type="VBoxContainer" parent="Margin/Root/Body/Details/DetailScroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="Tabs" type="HBoxContainer" parent="Margin/Root/Body/Details/DetailScroll/Detail"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 4
|
||||||
|
|
||||||
|
[node name="TabInspector" type="Button" parent="Margin/Root/Body/Details/DetailScroll/Detail/Tabs"]
|
||||||
|
layout_mode = 2
|
||||||
|
toggle_mode = true
|
||||||
|
button_pressed = true
|
||||||
|
|
||||||
|
[node name="TabProblems" type="Button" parent="Margin/Root/Body/Details/DetailScroll/Detail/Tabs"]
|
||||||
|
layout_mode = 2
|
||||||
|
toggle_mode = true
|
||||||
|
|
||||||
|
[node name="TabPlan" type="Button" parent="Margin/Root/Body/Details/DetailScroll/Detail/Tabs"]
|
||||||
|
layout_mode = 2
|
||||||
|
toggle_mode = true
|
||||||
|
|
||||||
|
[node name="DetailTitle" type="Label" parent="Margin/Root/Body/Details/DetailScroll/Detail"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 20
|
||||||
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="DetailMeta" type="Label" parent="Margin/Root/Body/Details/DetailScroll/Detail"]
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="DetailDescription" type="Label" parent="Margin/Root/Body/Details/DetailScroll/Detail"]
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="DetailExtra" type="VBoxContainer" parent="Margin/Root/Body/Details/DetailScroll/Detail"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 6
|
||||||
|
|
||||||
|
[node name="Apply" type="Button" parent="Margin/Root/Body/Details/DetailScroll/Detail"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="BuildBar" type="PanelContainer" parent="Margin/Root"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="BarRow" type="HBoxContainer" parent="Margin/Root/BuildBar"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="Banner" type="PanelContainer" parent="Margin/Root"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="BannerLabel" type="Label" parent="Margin/Root/Banner"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
horizontal_alignment = 1
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="src/ModManagerScreen.cs" id="1_screen"]
|
||||||
|
[ext_resource type="PackedScene" path="scenes/components/mod_filter_panel.tscn" id="2_filter"]
|
||||||
|
[ext_resource type="PackedScene" path="scenes/components/mod_grid_view.tscn" id="3_grid"]
|
||||||
|
[ext_resource type="PackedScene" path="scenes/components/mod_detail_view.tscn" id="4_detail"]
|
||||||
|
|
||||||
|
[node name="ModManagerScreen" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 0
|
||||||
|
script = ExtResource("1_screen")
|
||||||
|
|
||||||
|
[node name="Background" type="ColorRect" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
color = Color(0.054902, 0.058824, 0.082353, 1)
|
||||||
|
|
||||||
|
[node name="Margin" type="MarginContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/margin_left = 16
|
||||||
|
theme_override_constants/margin_top = 12
|
||||||
|
theme_override_constants/margin_right = 16
|
||||||
|
theme_override_constants/margin_bottom = 12
|
||||||
|
|
||||||
|
[node name="Root" type="VBoxContainer" parent="Margin"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="Header" type="HBoxContainer" parent="Margin/Root"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="Close" type="Button" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_override_font_sizes/font_size = 24
|
||||||
|
theme_override_colors/font_color = Color(0.956863, 0.941176, 0.905882, 1)
|
||||||
|
uppercase = true
|
||||||
|
|
||||||
|
[node name="Spacer" type="Control" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="Search" type="LineEdit" parent="Margin/Root/Header"]
|
||||||
|
custom_minimum_size = Vector2(220, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Sort" type="OptionButton" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Refresh" type="Button" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Result" type="Label" parent="Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
|
||||||
|
[node name="Body" type="HBoxContainer" parent="Margin/Root"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="Sidebar" type="PanelContainer" parent="Margin/Root/Body"]
|
||||||
|
custom_minimum_size = Vector2(260, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="SidebarScroll" type="ScrollContainer" parent="Margin/Root/Body/Sidebar"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="FilterPanel" parent="Margin/Root/Body/Sidebar/SidebarScroll" instance=ExtResource("2_filter")]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="Content" type="Control" parent="Margin/Root/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="Grid" parent="Margin/Root/Body/Content" instance=ExtResource("3_grid")]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
|
[node name="Detail" parent="Margin/Root/Body/Content" instance=ExtResource("4_detail")]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
|
[node name="Banner" type="PanelContainer" parent="Margin/Root"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="BannerLabel" type="Label" parent="Margin/Root/Banner"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
horizontal_alignment = 1
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="src/ModHubScreen.cs" id="1_hub"]
|
||||||
|
|
||||||
|
[node name="ModHubScreen" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 0
|
||||||
|
script = ExtResource("1_hub")
|
||||||
|
|
||||||
|
[node name="Background" type="ColorRect" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
color = Color(0.054902, 0.058824, 0.082353, 1)
|
||||||
|
|
||||||
|
[node name="Margin" type="MarginContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/margin_left = 16
|
||||||
|
theme_override_constants/margin_top = 12
|
||||||
|
theme_override_constants/margin_right = 16
|
||||||
|
theme_override_constants/margin_bottom = 12
|
||||||
|
|
||||||
|
[node name="Root" type="VBoxContainer" parent="Margin"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,371 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Cthangover.Core.Scenes;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Base.Lists;
|
||||||
|
using Cthangover.Mods.Interface.Theming;
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Single entry point of the mod UI (the manifest <c>main_menu</c> button
|
||||||
|
/// opens this hub instead of two separate screens). A slim header — back
|
||||||
|
/// button, title and a two-position segment control "Mods | Collections" —
|
||||||
|
/// hosts the child screens: the embedded <see cref="ModManagerScreen"/>
|
||||||
|
/// (default tab) and, on the collections tab, the
|
||||||
|
/// <see cref="CollectionsGallery"/> plus the scheme editor
|
||||||
|
/// <see cref="ModCollectionsScreen"/> as its second level. The selected
|
||||||
|
/// tab lives in a static field, so it survives closing and reopening the
|
||||||
|
/// hub within one game session.
|
||||||
|
/// <para/>
|
||||||
|
/// The hub also carries the mockup's cross links: a collection link on a
|
||||||
|
/// mod card switches to the collections tab, a "not found" problem in the
|
||||||
|
/// editor jumps back to the mods tab with the id filled into the search
|
||||||
|
/// box (<see cref="ModManagerScreen.SetSearch"/>), and Escape walks back
|
||||||
|
/// editor → gallery → mods tab.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ModHubScreen : ModWidget
|
||||||
|
{
|
||||||
|
private const string TabMods = "mods";
|
||||||
|
private const string TabCollections = "collections";
|
||||||
|
private const string ModsScene = "scenes/mod_manager_screen.tscn";
|
||||||
|
private const string EditorScene = "scenes/mod_collections_screen.tscn";
|
||||||
|
|
||||||
|
/// <summary>Active tab, remembered for the whole game session (mockup: "state is remembered").</summary>
|
||||||
|
private static string _activeTab = TabMods;
|
||||||
|
|
||||||
|
private Button _back;
|
||||||
|
private Label _title;
|
||||||
|
private HBoxContainer _segment;
|
||||||
|
private Button _tabMods;
|
||||||
|
private Button _tabCollections;
|
||||||
|
private LineEdit _gallerySearch;
|
||||||
|
private Button _galleryRefresh;
|
||||||
|
private PanelContainer _bannerPanel;
|
||||||
|
private Label _bannerLabel;
|
||||||
|
private Control _body;
|
||||||
|
|
||||||
|
private ModManagerScreen _mods;
|
||||||
|
private CollectionsGallery _gallery;
|
||||||
|
private ModCollectionsScreen _editor;
|
||||||
|
private bool _editorOpen;
|
||||||
|
|
||||||
|
/// <summary>Mods installed this session, shared by the gallery and the editor so both stay honest before a restart.</summary>
|
||||||
|
private readonly HashSet<string> _sessionInstalled = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
protected override void Construct()
|
||||||
|
{
|
||||||
|
MouseFilter = MouseFilterEnum.Stop;
|
||||||
|
CthangoverTheme.Apply(this);
|
||||||
|
|
||||||
|
BuildHeader();
|
||||||
|
BuildBanner();
|
||||||
|
BuildBody();
|
||||||
|
|
||||||
|
ShowTab(_activeTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════ Header and body ═══════════════ */
|
||||||
|
|
||||||
|
private void BuildHeader()
|
||||||
|
{
|
||||||
|
var root = GetNode<VBoxContainer>("Margin/Root");
|
||||||
|
root.AddThemeConstantOverride("separation", UiTokens.Space3);
|
||||||
|
|
||||||
|
var header = UiKit.Row(UiTokens.Space3);
|
||||||
|
root.AddChild(header);
|
||||||
|
|
||||||
|
_back = UiKit.Button(TranslationServer.Translate("mod_manager/hub_back_to_menu"));
|
||||||
|
_back.Pressed += OnBackPressed;
|
||||||
|
header.AddChild(_back);
|
||||||
|
|
||||||
|
_title = UiKit.Label(TranslationServer.Translate("mod_manager/menu_title"), UiTokens.FontLead, UiTokens.Ink);
|
||||||
|
_title.Uppercase = true;
|
||||||
|
header.AddChild(_title);
|
||||||
|
|
||||||
|
_segment = UiKit.Row(0);
|
||||||
|
var segmentPanel = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
|
||||||
|
segmentPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusSm, UiTokens.Well, UiTokens.Line));
|
||||||
|
segmentPanel.AddChild(_segment);
|
||||||
|
header.AddChild(segmentPanel);
|
||||||
|
|
||||||
|
var group = new ButtonGroup();
|
||||||
|
_tabMods = UiKit.Button(TranslationServer.Translate("mod_manager/hub_tab_mods"), ButtonKind.Tab);
|
||||||
|
_tabMods.ToggleMode = true;
|
||||||
|
_tabMods.ButtonGroup = group;
|
||||||
|
_tabMods.Pressed += () => ShowTab(TabMods);
|
||||||
|
_segment.AddChild(_tabMods);
|
||||||
|
|
||||||
|
_tabCollections = UiKit.Button(TranslationServer.Translate("mod_manager/hub_tab_collections"), ButtonKind.Tab);
|
||||||
|
_tabCollections.ToggleMode = true;
|
||||||
|
_tabCollections.ButtonGroup = group;
|
||||||
|
_tabCollections.Pressed += () => ShowTab(TabCollections);
|
||||||
|
_segment.AddChild(_tabCollections);
|
||||||
|
|
||||||
|
header.AddChild(UiKit.Spacer());
|
||||||
|
|
||||||
|
_gallerySearch = new LineEdit
|
||||||
|
{
|
||||||
|
PlaceholderText = TranslationServer.Translate("mod_manager/gallery_search_placeholder"),
|
||||||
|
CustomMinimumSize = new Vector2(220, 0),
|
||||||
|
SizeFlagsVertical = Control.SizeFlags.ShrinkCenter,
|
||||||
|
};
|
||||||
|
_gallerySearch.TextChanged += OnGallerySearchChanged;
|
||||||
|
header.AddChild(_gallerySearch);
|
||||||
|
|
||||||
|
_galleryRefresh = UiKit.Button(TranslationServer.Translate("mod_manager/refresh"));
|
||||||
|
_galleryRefresh.Pressed += OnGalleryRefreshPressed;
|
||||||
|
header.AddChild(_galleryRefresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildBanner()
|
||||||
|
{
|
||||||
|
var root = GetNode<VBoxContainer>("Margin/Root");
|
||||||
|
_bannerPanel = new PanelContainer { Visible = false };
|
||||||
|
_bannerPanel.AddThemeStyleboxOverride("panel", ModManagerTheme.MakeBox(
|
||||||
|
UiTokens.Alpha(UiTokens.Warning, 0.15f),
|
||||||
|
UiTokens.Alpha(UiTokens.Warning, 0.45f),
|
||||||
|
UiTokens.RadiusMd, UiTokens.Space4, UiTokens.Space2));
|
||||||
|
_bannerLabel = UiKit.Label(string.Empty, UiTokens.FontBodySmall, UiTokens.Warning);
|
||||||
|
_bannerLabel.HorizontalAlignment = HorizontalAlignment.Center;
|
||||||
|
_bannerPanel.AddChild(_bannerLabel);
|
||||||
|
root.AddChild(_bannerPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildBody()
|
||||||
|
{
|
||||||
|
_body = new Control
|
||||||
|
{
|
||||||
|
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||||
|
};
|
||||||
|
GetNode<VBoxContainer>("Margin/Root").AddChild(_body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════ Tabs and levels ═══════════════ */
|
||||||
|
|
||||||
|
private void ShowTab(string tab)
|
||||||
|
{
|
||||||
|
_activeTab = tab;
|
||||||
|
_editorOpen = false;
|
||||||
|
|
||||||
|
if (tab == TabMods)
|
||||||
|
{
|
||||||
|
EnsureMods();
|
||||||
|
_mods.Visible = true;
|
||||||
|
if (_gallery != null)
|
||||||
|
_gallery.Visible = false;
|
||||||
|
if (_editor != null)
|
||||||
|
_editor.Visible = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
EnsureGallery();
|
||||||
|
_gallery.Visible = true;
|
||||||
|
if (_mods != null)
|
||||||
|
_mods.Visible = false;
|
||||||
|
if (_editor != null)
|
||||||
|
_editor.Visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Leaves the editor back to the games gallery (crumbs, back button, Escape).</summary>
|
||||||
|
private void ShowGallery()
|
||||||
|
{
|
||||||
|
_editorOpen = false;
|
||||||
|
if (_editor != null)
|
||||||
|
_editor.Visible = false;
|
||||||
|
EnsureGallery();
|
||||||
|
_gallery.Visible = true;
|
||||||
|
UpdateHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deep link used by the main menu's game picker ("Configure"): shows
|
||||||
|
/// the collections tab with the scheme editor open on the game.
|
||||||
|
/// </summary>
|
||||||
|
public void OpenCollectionEditor(string collectionId) => OpenEditor(collectionId, false);
|
||||||
|
|
||||||
|
/// <summary>Opens the editor on the second level of the collections tab.</summary>
|
||||||
|
private void OpenEditor(string collectionId, bool newGame)
|
||||||
|
{
|
||||||
|
EnsureGallery();
|
||||||
|
EnsureEditor();
|
||||||
|
_gallery.Visible = false;
|
||||||
|
if (_mods != null)
|
||||||
|
_mods.Visible = false;
|
||||||
|
_editor.Visible = true;
|
||||||
|
_editorOpen = true;
|
||||||
|
|
||||||
|
if (newGame)
|
||||||
|
_editor.OpenNewGame();
|
||||||
|
else
|
||||||
|
_editor.OpenCollection(collectionId);
|
||||||
|
UpdateHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateHeader()
|
||||||
|
{
|
||||||
|
if (_editorOpen && _editor != null)
|
||||||
|
{
|
||||||
|
_back.Text = TranslationServer.Translate("mod_manager/hub_back_to_gallery");
|
||||||
|
_back.TooltipText = _back.Text;
|
||||||
|
_title.Text = _editor.CurrentCollectionTitle ?? TranslationServer.Translate("mod_manager/collections_title");
|
||||||
|
_segment.Visible = false;
|
||||||
|
_gallerySearch.Visible = false;
|
||||||
|
_galleryRefresh.Visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_back.Text = TranslationServer.Translate("mod_manager/hub_back_to_menu");
|
||||||
|
_back.TooltipText = _back.Text;
|
||||||
|
_title.Text = TranslationServer.Translate("mod_manager/menu_title");
|
||||||
|
_segment.Visible = true;
|
||||||
|
_tabMods.ButtonPressed = _activeTab == TabMods;
|
||||||
|
_tabCollections.ButtonPressed = _activeTab == TabCollections;
|
||||||
|
|
||||||
|
var collections = _activeTab == TabCollections;
|
||||||
|
_gallerySearch.Visible = collections;
|
||||||
|
_galleryRefresh.Visible = collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBackPressed()
|
||||||
|
{
|
||||||
|
if (_editorOpen)
|
||||||
|
ShowGallery();
|
||||||
|
else
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════ Hosted screens ═══════════════ */
|
||||||
|
|
||||||
|
private void EnsureMods()
|
||||||
|
{
|
||||||
|
if (_mods != null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_mods = TscnScenes.LoadAndBuild(ModsScene) as ModManagerScreen;
|
||||||
|
if (_mods == null)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MOD_HUB", $"Failed to build '{ModsScene}'", LogLevel.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_mods.Embedded = true;
|
||||||
|
_body.AddChild(_mods);
|
||||||
|
_mods.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
_mods.EnsureConstructed();
|
||||||
|
_mods.CollectionsLinkRequested += () => ShowTab(TabCollections);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureGallery()
|
||||||
|
{
|
||||||
|
if (_gallery != null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_gallery = new CollectionsGallery();
|
||||||
|
_gallery.SetSessionInstalled(_sessionInstalled);
|
||||||
|
_gallery.StatusMessage += ShowBanner;
|
||||||
|
_gallery.EditSchemeRequested += id => OpenEditor(id, newGame: false);
|
||||||
|
_gallery.NewGameRequested += () => OpenEditor(null, newGame: true);
|
||||||
|
_body.AddChild(_gallery);
|
||||||
|
_gallery.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
_gallery.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureEditor()
|
||||||
|
{
|
||||||
|
if (_editor != null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_editor = TscnScenes.LoadAndBuild(EditorScene) as ModCollectionsScreen;
|
||||||
|
if (_editor == null)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MOD_HUB", $"Failed to build '{EditorScene}'", LogLevel.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_editor.Embedded = true;
|
||||||
|
_editor.SetSessionInstalled(_sessionInstalled);
|
||||||
|
_editor.GalleryRequested += ShowGallery;
|
||||||
|
_editor.OpenManagerRequested += OnOpenManagerRequested;
|
||||||
|
_editor.ViewChanged += UpdateHeader;
|
||||||
|
_body.AddChild(_editor);
|
||||||
|
_editor.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
_editor.EnsureConstructed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOpenManagerRequested(string searchText)
|
||||||
|
{
|
||||||
|
ShowTab(TabMods);
|
||||||
|
_mods?.SetSearch(searchText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════ Gallery header controls ═══════════════ */
|
||||||
|
|
||||||
|
private void OnGallerySearchChanged(string text)
|
||||||
|
{
|
||||||
|
_gallery?.ApplyFilter(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnGalleryRefreshPressed()
|
||||||
|
{
|
||||||
|
if (_galleryRefresh.Disabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_galleryRefresh.Disabled = true;
|
||||||
|
ShowBanner(TranslationServer.Translate("mod_manager/loading"));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CatalogService.RefreshAsync(forceRefresh: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MOD_HUB", $"Catalog refresh failed: {ex.Message}", LogLevel.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!GodotObject.IsInstanceValid(this))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_galleryRefresh.Disabled = false;
|
||||||
|
ShowBanner(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowBanner(string text)
|
||||||
|
{
|
||||||
|
if (!GodotObject.IsInstanceValid(_bannerPanel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_bannerPanel.Visible = !string.IsNullOrEmpty(text);
|
||||||
|
if (!string.IsNullOrEmpty(text))
|
||||||
|
_bannerLabel.Text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════ Escape chain ═══════════════ */
|
||||||
|
|
||||||
|
/// <summary>Escape walks back one level: editor → gallery → mods tab.</summary>
|
||||||
|
public override void _UnhandledInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (!@event.IsActionPressed("ui_cancel"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_editorOpen)
|
||||||
|
{
|
||||||
|
ShowGallery();
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
}
|
||||||
|
else if (_activeTab == TabCollections)
|
||||||
|
{
|
||||||
|
ShowTab(TabMods);
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Base.Lists;
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Root of the mod manager screen. Opened from the main menu through the
|
||||||
|
/// manifest <c>main_menu</c> button as a fullscreen overlay; closing frees the
|
||||||
|
/// scene. The visual layout is decomposed into editable scenes
|
||||||
|
/// (<c>mod_filter_panel</c>, <c>mod_grid_view</c>/<c>mod_grid_group</c>,
|
||||||
|
/// <c>mod_card</c>, <c>mod_detail_view</c>) referenced from
|
||||||
|
/// <c>scenes/mod_manager_screen.tscn</c>; this class only wires them to the
|
||||||
|
/// model, runs mod operations (install/update/delete/enable) and reflects that
|
||||||
|
/// all of them only take effect after a restart.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ModManagerScreen : ModWidget
|
||||||
|
{
|
||||||
|
private ModManagerModel _model;
|
||||||
|
private ModFilterState _filter;
|
||||||
|
private ModGridView _grid;
|
||||||
|
private ModDetailView _detail;
|
||||||
|
private ModFilterPanel _filterPanel;
|
||||||
|
private LineEdit _search;
|
||||||
|
private OptionButton _sort;
|
||||||
|
private Label _resultLabel;
|
||||||
|
private PanelContainer _bannerPanel;
|
||||||
|
private Label _bannerLabel;
|
||||||
|
private Button _refreshButton;
|
||||||
|
|
||||||
|
private string _installingId;
|
||||||
|
private bool _restartRequired;
|
||||||
|
private string _statusMessage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merged collection store keyed by id (as produced by
|
||||||
|
/// <see cref="ModCollectionStore.LoadAll"/>), refreshed together with
|
||||||
|
/// the model; feeds the "in N collections" badge on every tile.
|
||||||
|
/// </summary>
|
||||||
|
private Dictionary<string, ModCollection> _collectionIndex;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true the screen runs as a tab of the mods hub: its own header
|
||||||
|
/// close button and title are hidden and closing is handled by the
|
||||||
|
/// hosting <see cref="ModHubScreen"/>. Must be set before construction;
|
||||||
|
/// filters, search, install and delete behaviour are unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public bool Embedded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised when a tile or the detail page asks to jump to the collections tab.</summary>
|
||||||
|
public event Action CollectionsLinkRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies an external search query — collection cross-links jump to
|
||||||
|
/// this tab with a mod id taken from a scheme problem. Updates the
|
||||||
|
/// search box and refreshes the grid without touching other filters.
|
||||||
|
/// </summary>
|
||||||
|
public void SetSearch(string text)
|
||||||
|
{
|
||||||
|
var query = text ?? string.Empty;
|
||||||
|
_filter.Search = query;
|
||||||
|
if (GodotObject.IsInstanceValid(_search)
|
||||||
|
&& !string.Equals(_search.Text, query, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_search.Text = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Construct()
|
||||||
|
{
|
||||||
|
MouseFilter = MouseFilterEnum.Stop;
|
||||||
|
CthangoverTheme.Apply(this);
|
||||||
|
_model = new ModManagerModel();
|
||||||
|
_filter = new ModFilterState();
|
||||||
|
|
||||||
|
BindSceneNodes();
|
||||||
|
|
||||||
|
SubscribeEvents();
|
||||||
|
CatalogService.LoadCache();
|
||||||
|
_model.Reload();
|
||||||
|
_filterPanel.Bind(_filter);
|
||||||
|
RefreshAll();
|
||||||
|
StartCatalogRefreshIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Destruct()
|
||||||
|
{
|
||||||
|
UnsubscribeEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fetches the scene-built nodes, sets static captions and hooks the layout controls.</summary>
|
||||||
|
private void BindSceneNodes()
|
||||||
|
{
|
||||||
|
var close = GetNode<Button>("Margin/Root/Header/Close");
|
||||||
|
var title = GetNode<Label>("Margin/Root/Header/Title");
|
||||||
|
_search = GetNode<LineEdit>("Margin/Root/Header/Search");
|
||||||
|
_sort = GetNode<OptionButton>("Margin/Root/Header/Sort");
|
||||||
|
_refreshButton = GetNode<Button>("Margin/Root/Header/Refresh");
|
||||||
|
_resultLabel = GetNode<Label>("Margin/Root/Header/Result");
|
||||||
|
_grid = GetNode<ModGridView>("Margin/Root/Body/Content/Grid");
|
||||||
|
_detail = GetNode<ModDetailView>("Margin/Root/Body/Content/Detail");
|
||||||
|
_filterPanel = GetNode<ModFilterPanel>("Margin/Root/Body/Sidebar/SidebarScroll/FilterPanel");
|
||||||
|
_bannerPanel = GetNode<PanelContainer>("Margin/Root/Banner");
|
||||||
|
_bannerLabel = GetNode<Label>("Margin/Root/Banner/BannerLabel");
|
||||||
|
|
||||||
|
close.Text = TranslationServer.Translate("mod_manager/back");
|
||||||
|
title.Text = TranslationServer.Translate("mod_manager/title");
|
||||||
|
if (Embedded)
|
||||||
|
{
|
||||||
|
// The hub header owns closing and the title; this row keeps
|
||||||
|
// only the search/sort/refresh controls.
|
||||||
|
close.Visible = false;
|
||||||
|
title.Visible = false;
|
||||||
|
}
|
||||||
|
_search.PlaceholderText = TranslationServer.Translate("mod_manager/search_placeholder");
|
||||||
|
_refreshButton.Text = TranslationServer.Translate("mod_manager/refresh");
|
||||||
|
_resultLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
|
||||||
|
_sort.AddItem(TranslationServer.Translate("mod_manager/sort_name"));
|
||||||
|
_sort.AddItem(TranslationServer.Translate("mod_manager/sort_author"));
|
||||||
|
_sort.AddItem(TranslationServer.Translate("mod_manager/sort_status"));
|
||||||
|
_sort.Selected = (int)_filter.Sort;
|
||||||
|
|
||||||
|
_bannerPanel.AddThemeStyleboxOverride("panel", ModManagerTheme.MakeBox(
|
||||||
|
UiTokens.Alpha(UiTokens.Warning, 0.15f),
|
||||||
|
UiTokens.Alpha(UiTokens.Warning, 0.45f),
|
||||||
|
UiTokens.RadiusMd, UiTokens.Space4, UiTokens.Space2));
|
||||||
|
_bannerLabel.AddThemeColorOverride("font_color", UiTokens.Warning);
|
||||||
|
_bannerPanel.Visible = false;
|
||||||
|
_detail.Visible = false;
|
||||||
|
|
||||||
|
close.Pressed += OnClosePressed;
|
||||||
|
_search.TextChanged += OnSearchChanged;
|
||||||
|
_sort.ItemSelected += OnSortSelected;
|
||||||
|
_refreshButton.Pressed += OnRefreshPressed;
|
||||||
|
_grid.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
_detail.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SubscribeEvents()
|
||||||
|
{
|
||||||
|
_filterPanel.Changed += OnFilterChanged;
|
||||||
|
_filterPanel.ResetRequested += OnFilterResetRequested;
|
||||||
|
_grid.CardClicked += OnCardClicked;
|
||||||
|
_grid.ActionPressed += OnActionPressed;
|
||||||
|
_grid.EnabledToggled += OnEnabledToggled;
|
||||||
|
_grid.VersionChanged += OnVersionChanged;
|
||||||
|
_grid.CollectionsLinkRequested += OnCollectionsLinkRequested;
|
||||||
|
_detail.BackRequested += OnDetailBackRequested;
|
||||||
|
_detail.ActionPressed += OnActionPressed;
|
||||||
|
_detail.DeleteRequested += OnDeleteRequested;
|
||||||
|
_detail.EnabledToggled += OnEnabledToggled;
|
||||||
|
_detail.VersionChanged += OnVersionChanged;
|
||||||
|
_detail.CollectionsLinkRequested += OnCollectionsLinkRequested;
|
||||||
|
ModDownloader.OnProgress += OnInstallProgress;
|
||||||
|
CatalogService.OnRefreshed += OnCatalogRefreshed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnsubscribeEvents()
|
||||||
|
{
|
||||||
|
_filterPanel.Changed -= OnFilterChanged;
|
||||||
|
_filterPanel.ResetRequested -= OnFilterResetRequested;
|
||||||
|
_grid.CardClicked -= OnCardClicked;
|
||||||
|
_grid.ActionPressed -= OnActionPressed;
|
||||||
|
_grid.EnabledToggled -= OnEnabledToggled;
|
||||||
|
_grid.VersionChanged -= OnVersionChanged;
|
||||||
|
_grid.CollectionsLinkRequested -= OnCollectionsLinkRequested;
|
||||||
|
_detail.BackRequested -= OnDetailBackRequested;
|
||||||
|
_detail.ActionPressed -= OnActionPressed;
|
||||||
|
_detail.DeleteRequested -= OnDeleteRequested;
|
||||||
|
_detail.EnabledToggled -= OnEnabledToggled;
|
||||||
|
_detail.VersionChanged -= OnVersionChanged;
|
||||||
|
_detail.CollectionsLinkRequested -= OnCollectionsLinkRequested;
|
||||||
|
ModDownloader.OnProgress -= OnInstallProgress;
|
||||||
|
CatalogService.OnRefreshed -= OnCatalogRefreshed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshAll()
|
||||||
|
{
|
||||||
|
_collectionIndex = BuildCollectionIndex();
|
||||||
|
_model.Reload();
|
||||||
|
RefreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rebuilds the merged collection index from the live store.</summary>
|
||||||
|
private static Dictionary<string, ModCollection> BuildCollectionIndex()
|
||||||
|
{
|
||||||
|
var index = new Dictionary<string, ModCollection>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var collection in ModCollectionStore.LoadAll())
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(collection?.Id))
|
||||||
|
index[collection.Id] = collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Counts playable games referencing the mod; zero when the store is unavailable.</summary>
|
||||||
|
private int CountGamesUsing(string modId)
|
||||||
|
{
|
||||||
|
if (_collectionIndex == null || _collectionIndex.Count == 0 || string.IsNullOrEmpty(modId))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return ModCollectionGallery.FindGamesUsingMod(_collectionIndex, modId)
|
||||||
|
.TryGetValue(modId, out var games) ? games.Count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebuilds grid and counters from the current model without re-reading
|
||||||
|
/// the registry/catalog. Filter, search and sort changes take this cheap
|
||||||
|
/// path; install/delete/enable and catalog refreshes call
|
||||||
|
/// <see cref="RefreshAll"/> first.
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshView()
|
||||||
|
{
|
||||||
|
var items = _filter.Apply(_model.Items);
|
||||||
|
foreach (var item in items)
|
||||||
|
item.CollectionsCount = CountGamesUsing(item.Id);
|
||||||
|
_grid.SetItems(items, _filter.GroupByCategory);
|
||||||
|
_filterPanel.RefreshCounts(_model.CountCategories(_filter), _model.CountStatuses(_filter));
|
||||||
|
_resultLabel.Text = string.Format(TranslationServer.Translate("mod_manager/results"), items.Count);
|
||||||
|
UpdateBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateBanner()
|
||||||
|
{
|
||||||
|
string text;
|
||||||
|
if (_restartRequired)
|
||||||
|
text = TranslationServer.Translate("mod_manager/restart_required");
|
||||||
|
else if (CatalogService.IsFetching)
|
||||||
|
text = TranslationServer.Translate("mod_manager/loading");
|
||||||
|
else
|
||||||
|
text = _statusMessage;
|
||||||
|
|
||||||
|
SetBanner(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetBanner(string text)
|
||||||
|
{
|
||||||
|
if (!GodotObject.IsInstanceValid(_bannerPanel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_bannerPanel.Visible = !string.IsNullOrEmpty(text);
|
||||||
|
if (!string.IsNullOrEmpty(text))
|
||||||
|
_bannerLabel.Text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Finds the refreshed model row matching a card, including its catalog variant.</summary>
|
||||||
|
private ModCardData FindItem(ModCardData data)
|
||||||
|
{
|
||||||
|
foreach (var item in _model.Items)
|
||||||
|
{
|
||||||
|
if (string.Equals(item.MatchKey, data.MatchKey, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartCatalogRefreshIfNeeded()
|
||||||
|
{
|
||||||
|
if (CatalogService.CachedEntries == null && ModConfig.Instance.CatalogUrls.Count > 0)
|
||||||
|
RefreshCatalog();
|
||||||
|
else
|
||||||
|
UpdateBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void RefreshCatalog()
|
||||||
|
{
|
||||||
|
if (GodotObject.IsInstanceValid(_refreshButton))
|
||||||
|
_refreshButton.Disabled = true;
|
||||||
|
UpdateBanner();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CatalogService.RefreshAsync(forceRefresh: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MOD_MANAGER", $"Catalog refresh failed: {ex.Message}", LogLevel.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!GodotObject.IsInstanceValid(this))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (GodotObject.IsInstanceValid(_refreshButton))
|
||||||
|
_refreshButton.Disabled = false;
|
||||||
|
UpdateBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCatalogRefreshed()
|
||||||
|
{
|
||||||
|
if (!GodotObject.IsInstanceValid(this))
|
||||||
|
return;
|
||||||
|
|
||||||
|
RefreshAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInstallProgress(InstallProgress progress)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_installingId) || progress == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
SetBanner(progress.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClosePressed()
|
||||||
|
{
|
||||||
|
QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetailBackRequested()
|
||||||
|
{
|
||||||
|
_detail.Visible = false;
|
||||||
|
_grid.Visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCardClicked(ModCardData data)
|
||||||
|
{
|
||||||
|
_detail.Open(data);
|
||||||
|
_detail.Visible = true;
|
||||||
|
_grid.Visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSearchChanged(string text)
|
||||||
|
{
|
||||||
|
_filter.Search = text;
|
||||||
|
RefreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSortSelected(long index)
|
||||||
|
{
|
||||||
|
_filter.Sort = (ModSortMode)(int)index;
|
||||||
|
RefreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFilterChanged()
|
||||||
|
{
|
||||||
|
RefreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCollectionsLinkRequested()
|
||||||
|
{
|
||||||
|
CollectionsLinkRequested?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFilterResetRequested()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_search.Text))
|
||||||
|
_search.Text = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnVersionChanged(ModCardData data)
|
||||||
|
{
|
||||||
|
_filterPanel.RefreshCounts(_model.CountCategories(_filter), _model.CountStatuses(_filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnActionPressed(ModCardData data)
|
||||||
|
{
|
||||||
|
var kind = ModActions.GetPrimary(data);
|
||||||
|
if (kind == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ExecuteAction(data, kind.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDeleteRequested(ModCardData data)
|
||||||
|
{
|
||||||
|
ExecuteAction(data, ModActionKind.Delete);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnabledToggled(ModCardData data)
|
||||||
|
{
|
||||||
|
_model.SetEnabled(data.Id, data.IsDisabled);
|
||||||
|
_restartRequired = true;
|
||||||
|
_statusMessage = null;
|
||||||
|
RefreshAll();
|
||||||
|
|
||||||
|
if (_detail.Visible)
|
||||||
|
{
|
||||||
|
var updated = FindItem(data);
|
||||||
|
if (updated != null)
|
||||||
|
_detail.Open(updated);
|
||||||
|
else
|
||||||
|
OnDetailBackRequested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExecuteAction(ModCardData data, ModActionKind kind)
|
||||||
|
{
|
||||||
|
if (kind == ModActionKind.Delete)
|
||||||
|
{
|
||||||
|
ConfirmDelete(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StartInstall(data, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void StartInstall(ModCardData data, ModActionKind kind)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_installingId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var version = data.SelectedDownload?.Version;
|
||||||
|
_installingId = data.Id;
|
||||||
|
_statusMessage = null;
|
||||||
|
SetBanner(string.Format(TranslationServer.Translate("mod_manager/progress_installing"), data.Title));
|
||||||
|
|
||||||
|
bool success;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
success = await ModDownloader.InstallAsync(data.Id, version, data.PrimarySource);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MOD_MANAGER", $"Install '{data.Id}' failed: {ex.Message}", LogLevel.Error);
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_installingId = null;
|
||||||
|
|
||||||
|
if (!GodotObject.IsInstanceValid(this))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
_model.MarkInstalled(data.Id, version);
|
||||||
|
_restartRequired = true;
|
||||||
|
_statusMessage = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_statusMessage = TranslationServer.Translate("mod_manager/install_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshAll();
|
||||||
|
|
||||||
|
if (_detail.Visible)
|
||||||
|
{
|
||||||
|
var updated = FindItem(data);
|
||||||
|
if (updated != null)
|
||||||
|
_detail.Open(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfirmDelete(ModCardData data)
|
||||||
|
{
|
||||||
|
var dialog = new ConfirmationDialog
|
||||||
|
{
|
||||||
|
Title = TranslationServer.Translate("mod_manager/delete_title"),
|
||||||
|
DialogText = string.Format(TranslationServer.Translate("mod_manager/delete_text"), data.Title),
|
||||||
|
OkButtonText = TranslationServer.Translate("mod_manager/delete_confirm"),
|
||||||
|
CancelButtonText = TranslationServer.Translate("mod_manager/cancel"),
|
||||||
|
Exclusive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var modId = data.Id;
|
||||||
|
dialog.Confirmed += () =>
|
||||||
|
{
|
||||||
|
OnDeleteConfirmed(modId);
|
||||||
|
dialog.QueueFree();
|
||||||
|
};
|
||||||
|
dialog.Canceled += dialog.QueueFree;
|
||||||
|
|
||||||
|
AddChild(dialog);
|
||||||
|
dialog.PopupCentered();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDeleteConfirmed(string modId)
|
||||||
|
{
|
||||||
|
if (!ModUninstaller.Uninstall(modId))
|
||||||
|
{
|
||||||
|
_statusMessage = TranslationServer.Translate("mod_manager/delete_failed");
|
||||||
|
UpdateBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_model.MarkRemoved(modId);
|
||||||
|
_restartRequired = true;
|
||||||
|
_statusMessage = null;
|
||||||
|
|
||||||
|
if (_detail.Visible)
|
||||||
|
{
|
||||||
|
_detail.Visible = false;
|
||||||
|
_grid.Visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRefreshPressed()
|
||||||
|
{
|
||||||
|
RefreshCatalog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// User-triggered mod operation. The screen resolves a tile's primary action
|
||||||
|
/// via <see cref="ModActions.GetPrimary"/> and executes the kind here; the
|
||||||
|
/// detail view maps its buttons to the same kinds. Every kind is persisted to
|
||||||
|
/// disk immediately but only takes effect after a restart.
|
||||||
|
/// </summary>
|
||||||
|
public enum ModActionKind
|
||||||
|
{
|
||||||
|
/// <summary>Download and install the selected catalog version.</summary>
|
||||||
|
Install,
|
||||||
|
|
||||||
|
/// <summary>Install a newer catalog version over the local copy.</summary>
|
||||||
|
Update,
|
||||||
|
|
||||||
|
/// <summary>Install an older catalog version over the local copy.</summary>
|
||||||
|
Downgrade,
|
||||||
|
|
||||||
|
/// <summary>Reinstall the same version to replace locally modified files.</summary>
|
||||||
|
Reinstall,
|
||||||
|
|
||||||
|
/// <summary>Delete the installed mod (user mods root only), then clean per-mod state.</summary>
|
||||||
|
Delete,
|
||||||
|
|
||||||
|
/// <summary>Allow the mod to load on next start.</summary>
|
||||||
|
Enable,
|
||||||
|
|
||||||
|
/// <summary>Prevent the mod from loading on next start.</summary>
|
||||||
|
Disable,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Business rules deciding which operation a mod tile offers. The tile shows
|
||||||
|
/// a single contextual button; its meaning follows the state machine:
|
||||||
|
/// catalog-only → Install, newer remote → Update, older remote → Downgrade,
|
||||||
|
/// same version but edited locally → Reinstall, otherwise → Delete for
|
||||||
|
/// deletable local mods and nothing for protected/shipped ones.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModActions
|
||||||
|
{
|
||||||
|
/// <summary>Primary action for a tile/detail page, or null when there is nothing to do.</summary>
|
||||||
|
public static ModActionKind? GetPrimary(ModCardData data)
|
||||||
|
{
|
||||||
|
if (data == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!data.IsInstalled)
|
||||||
|
return data.HasRemote ? ModActionKind.Install : null;
|
||||||
|
|
||||||
|
if (data.HasRemote)
|
||||||
|
{
|
||||||
|
switch (data.Status)
|
||||||
|
{
|
||||||
|
case ModItemStatus.UpdateAvailable:
|
||||||
|
return ModActionKind.Update;
|
||||||
|
case ModItemStatus.DowngradeAvailable:
|
||||||
|
return ModActionKind.Downgrade;
|
||||||
|
case ModItemStatus.ModifiedLocally:
|
||||||
|
return ModActionKind.Reinstall;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.IsDeletable ? ModActionKind.Delete : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True for actions that download and install files.</summary>
|
||||||
|
public static bool IsInstall(ModActionKind kind)
|
||||||
|
=> kind == ModActionKind.Install
|
||||||
|
|| kind == ModActionKind.Update
|
||||||
|
|| kind == ModActionKind.Downgrade
|
||||||
|
|| kind == ModActionKind.Reinstall;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One row of the mod manager grid: everything the tile and the detail page
|
||||||
|
/// need to render a mod, regardless of whether it exists locally, in the
|
||||||
|
/// online catalog, or both. The manager builds these in
|
||||||
|
/// <see cref="ModManagerModel.Reload"/> by merging
|
||||||
|
/// <c>ModRegistry.AllDiscoveredMods</c> with <c>CatalogService.CachedEntries</c>.
|
||||||
|
/// <para/>
|
||||||
|
/// <see cref="Status"/> is derived (not stored by the registry) from the local
|
||||||
|
/// version, the selected catalog download and the local install hash — call
|
||||||
|
/// <see cref="RecomputeStatus"/> after changing <see cref="SelectedDownload"/>,
|
||||||
|
/// <see cref="IsDisabled"/> or the install state.
|
||||||
|
/// </summary>
|
||||||
|
public class ModCardData
|
||||||
|
{
|
||||||
|
/// <summary>Stable mod id shared by the local mod and the catalog entry.</summary>
|
||||||
|
public string Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Localized display title (local manifest name or catalog locale name).</summary>
|
||||||
|
public string Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Author string from whichever source is available.</summary>
|
||||||
|
public string Author { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Localized description.</summary>
|
||||||
|
public string Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Installed version string, or null for catalog-only mods.</summary>
|
||||||
|
public string LocalVersion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Manifest category (local) or catalog category (remote), flags combined.</summary>
|
||||||
|
public CategoryType Category { get; set; } = CategoryType.Other;
|
||||||
|
|
||||||
|
/// <summary>Loaded local mod, or null when only the catalog knows this id.</summary>
|
||||||
|
public ModInfo LocalInfo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Merged catalog entry, or null for local-only (development) mods.</summary>
|
||||||
|
public CatalogExportEntry Catalog { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when a local copy exists (including an install done in this session).</summary>
|
||||||
|
public bool IsInstalled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when the local copy is listed in <c>disabled_mods.json</c>.</summary>
|
||||||
|
public bool IsDisabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when <see cref="ModUninstaller"/> may delete the local copy.</summary>
|
||||||
|
public bool IsDeletable { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True for the mod manager itself, which cannot be disabled or deleted.</summary>
|
||||||
|
public bool IsProtected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Derived tile state; see <see cref="RecomputeStatus"/>.</summary>
|
||||||
|
public ModItemStatus Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Catalog downloads, newest first (as merged by the catalog service).</summary>
|
||||||
|
public List<CatalogEntryDownload> Downloads { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Version chosen in the tile/detail combo box; defaults to the newest download.</summary>
|
||||||
|
public CatalogEntryDownload SelectedDownload { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when an online catalog entry exists for this mod.</summary>
|
||||||
|
public bool HasRemote => Catalog != null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when several catalogs publish this id with the same version and
|
||||||
|
/// title but different content — the tile and detail page warn about it.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsConflict => Catalog?.VariantConflict == true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many <see cref="ModCollectionKind.Game"/> collections reference
|
||||||
|
/// this mod (directly or through includes). Computed by the manager
|
||||||
|
/// screen via <see cref="ModCollectionGallery.FindGamesUsingMod"/> on
|
||||||
|
/// every grid refresh; zero means the mod belongs to no playable game.
|
||||||
|
/// </summary>
|
||||||
|
public int CollectionsCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Catalog URLs that published this variant, or null for local-only mods.</summary>
|
||||||
|
public IReadOnlyList<string> SourceUrls => Catalog?.SourceUrls;
|
||||||
|
|
||||||
|
/// <summary>First catalog URL; used to install exactly this variant and to match cards after reload.</summary>
|
||||||
|
public string PrimarySource => ModCatalogSource.FirstUrl(SourceUrls);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identity of a card across model reloads: the mod id plus the catalog
|
||||||
|
/// variant it represents. Needed because conflicting variants share an id.
|
||||||
|
/// </summary>
|
||||||
|
public string MatchKey => $"{Id}|{PrimarySource ?? string.Empty}";
|
||||||
|
|
||||||
|
/// <summary>True when the tile should show the enable/disable switch.</summary>
|
||||||
|
public bool CanToggleEnabled => IsInstalled && !IsProtected;
|
||||||
|
|
||||||
|
/// <summary>Version to display next to the title: installed version or newest catalog version.</summary>
|
||||||
|
public string DisplayVersion => !string.IsNullOrEmpty(LocalVersion)
|
||||||
|
? LocalVersion
|
||||||
|
: SelectedDownload?.Version ?? Catalog?.Version;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives <see cref="Status"/> from the local and selected remote versions.
|
||||||
|
/// Priority: not installed → disabled → version comparison →
|
||||||
|
/// hash comparison. A missing catalog entry always yields
|
||||||
|
/// <see cref="ModItemStatus.Installed"/> for a local copy.
|
||||||
|
/// </summary>
|
||||||
|
public void RecomputeStatus()
|
||||||
|
{
|
||||||
|
if (!IsInstalled)
|
||||||
|
{
|
||||||
|
Status = ModItemStatus.NotInstalled;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsDisabled)
|
||||||
|
{
|
||||||
|
Status = ModItemStatus.Disabled;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteVersion = SelectedDownload?.Version ?? Catalog?.Version;
|
||||||
|
if (string.IsNullOrEmpty(remoteVersion) || string.IsNullOrEmpty(LocalVersion))
|
||||||
|
{
|
||||||
|
Status = ModItemStatus.Installed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var comparison = VersionHelper.Compare(remoteVersion, LocalVersion);
|
||||||
|
if (comparison > 0)
|
||||||
|
{
|
||||||
|
Status = ModItemStatus.UpdateAvailable;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comparison < 0)
|
||||||
|
{
|
||||||
|
Status = ModItemStatus.DowngradeAvailable;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteHash = SelectedDownload?.InstallSha256;
|
||||||
|
if (!string.IsNullOrEmpty(remoteHash)
|
||||||
|
&& !string.IsNullOrEmpty(LocalInfo?.InstallSha256)
|
||||||
|
&& !string.Equals(LocalInfo.InstallSha256, remoteHash, System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Status = ModItemStatus.ModifiedLocally;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status = ModItemStatus.Installed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One row of the "+ мод" picker: the mods known to the local registry and
|
||||||
|
/// the online catalog cache, merged by id. The picker excludes ids that are
|
||||||
|
/// already resolved in the scheme, so this entity only ever describes mods
|
||||||
|
/// the player can still add; <see cref="ModChoice.Id"/> becomes a new string
|
||||||
|
/// in the target mechanic's <c>mods</c> array.
|
||||||
|
/// </summary>
|
||||||
|
public class ModChoice
|
||||||
|
{
|
||||||
|
/// <summary>Canonical mod id written into the collection document.</summary>
|
||||||
|
public string Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Display title (localized catalog name or manifest name).</summary>
|
||||||
|
public string Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Localized description shown under the title.</summary>
|
||||||
|
public string Description { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Live filter state of the manager. Empty status/category sets mean "all",
|
||||||
|
/// which matches Nexus-style sidebars where checkboxes only ever narrow the
|
||||||
|
/// list. Search matches title, id and author case-insensitively. The state
|
||||||
|
/// also owns the sort mode and the optional grouping toggle.
|
||||||
|
/// </summary>
|
||||||
|
public class ModFilterState
|
||||||
|
{
|
||||||
|
/// <summary>Free-text query matched against title, id and author.</summary>
|
||||||
|
public string Search { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Selected statuses; empty means no status restriction.</summary>
|
||||||
|
public HashSet<ModItemStatus> Statuses { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Selected categories; empty means no category restriction.</summary>
|
||||||
|
public HashSet<CategoryType> Categories { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Grid ordering mode.</summary>
|
||||||
|
public ModSortMode Sort { get; set; } = ModSortMode.Name;
|
||||||
|
|
||||||
|
/// <summary>When true the grid renders category headers between groups.</summary>
|
||||||
|
public bool GroupByCategory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Full match: search + status + category.</summary>
|
||||||
|
public bool Matches(ModCardData item)
|
||||||
|
=> MatchesSearch(item) && MatchesStatuses(item) && MatchesCategories(item);
|
||||||
|
|
||||||
|
/// <summary>Match ignoring the category set — used to compute category counts.</summary>
|
||||||
|
public bool MatchesExceptCategory(ModCardData item)
|
||||||
|
=> MatchesSearch(item) && MatchesStatuses(item);
|
||||||
|
|
||||||
|
/// <summary>Match ignoring the status set — used to compute status counts.</summary>
|
||||||
|
public bool MatchesExceptStatus(ModCardData item)
|
||||||
|
=> MatchesSearch(item) && MatchesCategories(item);
|
||||||
|
|
||||||
|
/// <summary>Returns matching items in the configured order.</summary>
|
||||||
|
public List<ModCardData> Apply(IEnumerable<ModCardData> source)
|
||||||
|
{
|
||||||
|
var result = new List<ModCardData>();
|
||||||
|
foreach (var item in source)
|
||||||
|
{
|
||||||
|
if (Matches(item))
|
||||||
|
result.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Sort(Compare);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clears search, statuses and categories; keeps sort and grouping.</summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
Search = string.Empty;
|
||||||
|
Statuses.Clear();
|
||||||
|
Categories.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int Compare(ModCardData a, ModCardData b)
|
||||||
|
{
|
||||||
|
switch (Sort)
|
||||||
|
{
|
||||||
|
case ModSortMode.Author:
|
||||||
|
var byAuthor = string.Compare(a.Author, b.Author, StringComparison.OrdinalIgnoreCase);
|
||||||
|
return byAuthor != 0 ? byAuthor : CompareTitle(a, b);
|
||||||
|
|
||||||
|
case ModSortMode.Status:
|
||||||
|
var byStatus = ModStatusNames.SortRank(a.Status).CompareTo(ModStatusNames.SortRank(b.Status));
|
||||||
|
return byStatus != 0 ? byStatus : CompareTitle(a, b);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return CompareTitle(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CompareTitle(ModCardData a, ModCardData b)
|
||||||
|
=> string.Compare(a.Title, b.Title, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private bool MatchesSearch(ModCardData item)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Search))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var query = Search.Trim();
|
||||||
|
return Contains(item.Title, query) || Contains(item.Id, query) || Contains(item.Author, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool MatchesStatuses(ModCardData item)
|
||||||
|
=> Statuses.Count == 0 || Statuses.Contains(item.Status);
|
||||||
|
|
||||||
|
private bool MatchesCategories(ModCardData item)
|
||||||
|
=> Categories.Count == 0 || (item.Category & AsFlags()) != 0;
|
||||||
|
|
||||||
|
private CategoryType AsFlags()
|
||||||
|
{
|
||||||
|
var flags = (CategoryType)0;
|
||||||
|
foreach (var category in Categories)
|
||||||
|
flags |= category;
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Contains(string value, string query)
|
||||||
|
=> !string.IsNullOrEmpty(value) && value.Contains(query, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// UI-level state of a mod tile. Combines the local install state with the
|
||||||
|
/// online catalog comparison so a single value drives the tile badge and the
|
||||||
|
/// contextual action button. <see cref="ModifiedLocally"/> means the local
|
||||||
|
/// content hash differs from the catalog entry of the same version — the
|
||||||
|
/// player edited the mod and overwriting would lose their changes.
|
||||||
|
/// </summary>
|
||||||
|
public enum ModItemStatus
|
||||||
|
{
|
||||||
|
/// <summary>No local copy exists; only the catalog entry is shown.</summary>
|
||||||
|
NotInstalled,
|
||||||
|
|
||||||
|
/// <summary>Installed and up-to-date with the selected catalog version (or no catalog entry).</summary>
|
||||||
|
Installed,
|
||||||
|
|
||||||
|
/// <summary>The catalog has a newer version than the installed one.</summary>
|
||||||
|
UpdateAvailable,
|
||||||
|
|
||||||
|
/// <summary>The catalog's selected version is older than the installed one.</summary>
|
||||||
|
DowngradeAvailable,
|
||||||
|
|
||||||
|
/// <summary>Same version as the catalog, but the local content hash differs.</summary>
|
||||||
|
ModifiedLocally,
|
||||||
|
|
||||||
|
/// <summary>Installed but disabled by the player (<c>disabled_mods.json</c>).</summary>
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Merges everything known about mods into the manager's grid model: local
|
||||||
|
/// mods discovered by <see cref="ModRegistry"/> (with enable state and
|
||||||
|
/// deletability) and online catalog entries from <see cref="CatalogService"/>.
|
||||||
|
/// A mod present in both sources becomes one combined card; catalog-only mods
|
||||||
|
/// appear as installable tiles.
|
||||||
|
/// <para/>
|
||||||
|
/// The model also owns the session overrides that keep the UI honest after
|
||||||
|
/// file operations: a mod installed or deleted from the manager is not yet
|
||||||
|
/// reflected by the registry (which is loaded once per launch), so
|
||||||
|
/// <see cref="MarkInstalled"/> / <see cref="MarkRemoved"/> adjust the view and
|
||||||
|
/// the restart banner tells the player when the change becomes real.
|
||||||
|
/// </summary>
|
||||||
|
public class ModManagerModel
|
||||||
|
{
|
||||||
|
/// <summary>Id of the mod that renders this UI; it can never be disabled or removed.</summary>
|
||||||
|
public const string SelfModId = "mod_manager";
|
||||||
|
|
||||||
|
private readonly List<ModCardData> _items = new();
|
||||||
|
private readonly HashSet<string> _removed = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, string> _installed = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>All cards, unsorted and unfiltered; rebuild with <see cref="Reload"/>.</summary>
|
||||||
|
public IReadOnlyList<ModCardData> Items => _items;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebuilds the card list from the current registry, disabled store and
|
||||||
|
/// catalog cache. Safe to call on every action completion and after a
|
||||||
|
/// catalog refresh.
|
||||||
|
/// </summary>
|
||||||
|
public void Reload()
|
||||||
|
{
|
||||||
|
_items.Clear();
|
||||||
|
|
||||||
|
var disabled = DisabledModsStore.Load();
|
||||||
|
var variantsById = BuildCatalogVariants();
|
||||||
|
var localIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var ordered = ModRegistry.Instance.GetOrderedModIds(filterDisabled: false);
|
||||||
|
foreach (var id in ordered)
|
||||||
|
{
|
||||||
|
if (_removed.Contains(id))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!ModRegistry.Instance.AllDiscoveredMods.TryGetValue(id, out var info) || info is not ModInfo modInfo)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
localIds.Add(id);
|
||||||
|
var isDisabled = disabled.Contains(id);
|
||||||
|
|
||||||
|
if (variantsById.TryGetValue(id, out var variants))
|
||||||
|
{
|
||||||
|
foreach (var variant in variants)
|
||||||
|
_items.Add(BuildLocalCard(id, modInfo, isDisabled, variant));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_items.Add(BuildLocalCard(id, modInfo, isDisabled, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in variantsById)
|
||||||
|
{
|
||||||
|
if (localIds.Contains(pair.Key) || _removed.Contains(pair.Key))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (var variant in pair.Value)
|
||||||
|
_items.Add(BuildRemoteCard(variant));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Remembers that a mod was deleted this session so it stays hidden until restart.</summary>
|
||||||
|
public void MarkRemoved(string modId)
|
||||||
|
{
|
||||||
|
_removed.Add(modId);
|
||||||
|
_installed.Remove(modId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Remembers a successful install so the card shows as installed before restart.</summary>
|
||||||
|
public void MarkInstalled(string modId, string version)
|
||||||
|
{
|
||||||
|
_removed.Remove(modId);
|
||||||
|
_installed[modId] = version ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies an enable/disable toggle with the same cascade as the legacy
|
||||||
|
/// menu: disabling a mod also disables its dependents, enabling one also
|
||||||
|
/// re-enables the dependencies it needs.
|
||||||
|
/// </summary>
|
||||||
|
public void SetEnabled(string modId, bool enabled)
|
||||||
|
{
|
||||||
|
var disabled = DisabledModsStore.Load();
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
disabled.Remove(modId);
|
||||||
|
var info = ModRegistry.Instance.GetMod(modId) as ModInfo;
|
||||||
|
var dependencies = info?.Manifest?.DependencyIds;
|
||||||
|
if (dependencies != null)
|
||||||
|
{
|
||||||
|
foreach (var dependency in dependencies)
|
||||||
|
disabled.Remove(dependency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
disabled.Add(modId);
|
||||||
|
CollectDependents(modId, disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
DisabledModsStore.Save(disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Category buckets for the sidebar, respecting every filter except the category set.</summary>
|
||||||
|
public Dictionary<CategoryType, int> CountCategories(ModFilterState filter)
|
||||||
|
{
|
||||||
|
var counts = new Dictionary<CategoryType, int>();
|
||||||
|
foreach (var item in _items)
|
||||||
|
{
|
||||||
|
if (!filter.MatchesExceptCategory(item))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (var category in ModCategoryNames.Ordered)
|
||||||
|
{
|
||||||
|
if ((item.Category & category) == 0)
|
||||||
|
continue;
|
||||||
|
counts[category] = counts.GetValueOrDefault(category) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Status buckets for the sidebar, respecting every filter except the status set.</summary>
|
||||||
|
public Dictionary<ModItemStatus, int> CountStatuses(ModFilterState filter)
|
||||||
|
{
|
||||||
|
var counts = new Dictionary<ModItemStatus, int>();
|
||||||
|
foreach (var item in _items)
|
||||||
|
{
|
||||||
|
if (!filter.MatchesExceptStatus(item))
|
||||||
|
continue;
|
||||||
|
counts[item.Status] = counts.GetValueOrDefault(item.Status) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indexes catalog entries by mod id. Normally one entry per id, but a
|
||||||
|
/// variant conflict (same id/version/title, different content across
|
||||||
|
/// catalogs) produces several entries that the UI shows as separate mods.
|
||||||
|
/// </summary>
|
||||||
|
private Dictionary<string, List<CatalogExportEntry>> BuildCatalogVariants()
|
||||||
|
{
|
||||||
|
var index = new Dictionary<string, List<CatalogExportEntry>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var entries = CatalogService.CachedEntries;
|
||||||
|
if (entries == null)
|
||||||
|
return index;
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(entry?.Id))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!index.TryGetValue(entry.Id, out var list))
|
||||||
|
{
|
||||||
|
list = new List<CatalogExportEntry>();
|
||||||
|
index[entry.Id] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ModCardData BuildLocalCard(string id, ModInfo modInfo, bool disabled, CatalogExportEntry catalog)
|
||||||
|
{
|
||||||
|
var data = new ModCardData
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Title = modInfo.DisplayTitle,
|
||||||
|
Author = modInfo.Author,
|
||||||
|
Description = modInfo.LocalizedDescription,
|
||||||
|
LocalVersion = modInfo.Version,
|
||||||
|
Category = modInfo.Manifest?.Category ?? CategoryType.Other,
|
||||||
|
LocalInfo = modInfo,
|
||||||
|
Catalog = catalog,
|
||||||
|
IsInstalled = true,
|
||||||
|
IsDisabled = disabled,
|
||||||
|
IsDeletable = ModUninstaller.CanUninstall(modInfo),
|
||||||
|
IsProtected = string.Equals(id, SelfModId, StringComparison.OrdinalIgnoreCase),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_installed.TryGetValue(id, out var installedVersion))
|
||||||
|
data.LocalVersion = installedVersion;
|
||||||
|
|
||||||
|
ApplyDownloads(data, catalog);
|
||||||
|
data.RecomputeStatus();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ModCardData BuildRemoteCard(CatalogExportEntry catalog)
|
||||||
|
{
|
||||||
|
var locale = GetCatalogLocale();
|
||||||
|
var data = new ModCardData
|
||||||
|
{
|
||||||
|
Id = catalog.Id,
|
||||||
|
Title = catalog.GetLocalizedName(locale),
|
||||||
|
Author = catalog.Author,
|
||||||
|
Description = catalog.GetLocalizedDescription(locale),
|
||||||
|
Category = catalog.Category,
|
||||||
|
Catalog = catalog,
|
||||||
|
IsInstalled = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_installed.TryGetValue(catalog.Id, out var installedVersion))
|
||||||
|
{
|
||||||
|
data.IsInstalled = true;
|
||||||
|
data.LocalVersion = installedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyDownloads(data, catalog);
|
||||||
|
data.RecomputeStatus();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyDownloads(ModCardData data, CatalogExportEntry catalog)
|
||||||
|
{
|
||||||
|
data.Downloads = catalog?.Downloads ?? new List<CatalogEntryDownload>();
|
||||||
|
data.SelectedDownload = data.Downloads.Count > 0 ? data.Downloads[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCatalogLocale()
|
||||||
|
{
|
||||||
|
var locale = TranslationServer.GetLocale();
|
||||||
|
return string.IsNullOrEmpty(locale) ? "en" : locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectDependents(string modId, HashSet<string> disabled)
|
||||||
|
{
|
||||||
|
var allMods = ModRegistry.Instance.AllDiscoveredMods;
|
||||||
|
foreach (var pair in allMods)
|
||||||
|
{
|
||||||
|
if (disabled.Contains(pair.Key) || pair.Value is not ModInfo info)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var dependencies = info.Manifest?.DependencyIds;
|
||||||
|
if (dependencies != null && dependencies.Contains(modId))
|
||||||
|
{
|
||||||
|
disabled.Add(pair.Key);
|
||||||
|
CollectDependents(pair.Key, disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only cross-mod lookups used by the detail page: direct dependencies,
|
||||||
|
/// reverse dependencies (which mods depend on this one) and declared
|
||||||
|
/// conflicts. Prefers local manifest data and falls back to the catalog entry
|
||||||
|
/// for mods that are not installed.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModQueries
|
||||||
|
{
|
||||||
|
/// <summary>Dependency strings (<c>core>=1</c> format) of a mod, whatever source knows it.</summary>
|
||||||
|
public static List<string> GetDependencies(ModCardData data)
|
||||||
|
{
|
||||||
|
var manifestDeps = data?.LocalInfo?.Manifest?.DependencyIds;
|
||||||
|
if (manifestDeps != null && manifestDeps.Count > 0)
|
||||||
|
return manifestDeps;
|
||||||
|
|
||||||
|
return data?.Catalog?.Depends ?? new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ids of installed mods whose manifests depend on <paramref name="modId"/>.</summary>
|
||||||
|
public static List<string> GetDependents(string modId)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
if (string.IsNullOrEmpty(modId))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var allMods = ModRegistry.Instance.AllDiscoveredMods;
|
||||||
|
if (allMods == null)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
foreach (var pair in allMods)
|
||||||
|
{
|
||||||
|
if (pair.Key == modId || pair.Value is not ModInfo info)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var deps = info.Manifest?.DependencyIds;
|
||||||
|
if (deps != null && deps.Contains(modId))
|
||||||
|
result.Add(pair.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Declared mutually exclusive mod ids (manifest first, catalog fallback).</summary>
|
||||||
|
public static List<string> GetConflicts(ModCardData data)
|
||||||
|
{
|
||||||
|
var manifestConflicts = data?.LocalInfo?.Manifest?.ConflictsWith;
|
||||||
|
if (manifestConflicts != null && manifestConflicts.Count > 0)
|
||||||
|
return manifestConflicts;
|
||||||
|
|
||||||
|
return data?.Catalog?.ConflictsWith ?? new List<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>Ordering mode of the mod grid; grouping by category is a separate toggle.</summary>
|
||||||
|
public enum ModSortMode
|
||||||
|
{
|
||||||
|
/// <summary>Alphabetical by display title.</summary>
|
||||||
|
Name,
|
||||||
|
|
||||||
|
/// <summary>Alphabetical by author, then title.</summary>
|
||||||
|
Author,
|
||||||
|
|
||||||
|
/// <summary>By install/update state (installed, updates, catalog-only).</summary>
|
||||||
|
Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Cthangover.Core.Mods;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One dependency line of the scheme: the two chips it connects plus the
|
||||||
|
/// flags that decide its paint style. Edges are rebuilt on every canvas
|
||||||
|
/// rebuild and drawn by <see cref="CollectionEdgeLayer"/> from the chips'
|
||||||
|
/// live global rectangles, so dragging a chip or a whole group keeps the
|
||||||
|
/// lines attached without recomputing them.
|
||||||
|
/// <para/>
|
||||||
|
/// <see cref="Cross"/> is the "dangerous layout" flag: the dependency's two
|
||||||
|
/// mods live in different mechanics, which means the player separated what
|
||||||
|
/// belongs together. Cross edges are painted amber (red when the selection
|
||||||
|
/// touches them) so the mistake is visible across the whole canvas.
|
||||||
|
/// </summary>
|
||||||
|
public class CollectionEdge
|
||||||
|
{
|
||||||
|
/// <summary>Mod id of the dependency (the side the arrow starts at).</summary>
|
||||||
|
public string FromModId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mod id of the dependent mod.</summary>
|
||||||
|
public string ToModId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when the two mods belong to different groups.</summary>
|
||||||
|
public bool Cross { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when the selection touches one of the endpoints.</summary>
|
||||||
|
public bool Active { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Base.Lists;
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Transparent overlay that paints the scheme's dependency lines above the
|
||||||
|
/// group cards, the Godot counterpart of the mockup's SVG layer. Drawing is
|
||||||
|
/// connected through the <c>"draw"</c> signal rather than a <c>_Draw</c>
|
||||||
|
/// override, because virtual overrides are unreliable in mod assemblies.
|
||||||
|
/// <para/>
|
||||||
|
/// The layer stores only edge descriptors and chip references; coordinates
|
||||||
|
/// are read from the chips' global rectangles at paint time, so the canvas
|
||||||
|
/// never has to update line geometry — it only calls <see cref="QueueRedraw"/>
|
||||||
|
/// after a drag or resize.
|
||||||
|
/// </summary>
|
||||||
|
public partial class CollectionEdgeLayer : ModWidget
|
||||||
|
{
|
||||||
|
private readonly List<CollectionEdge> _edges = new();
|
||||||
|
private readonly Dictionary<string, CollectionModChip> _chips = new();
|
||||||
|
private Control _canvas;
|
||||||
|
|
||||||
|
/// <summary>Replaces the drawn edges. <paramref name="canvas"/> anchors the local coordinate space.</summary>
|
||||||
|
public void SetEdges(IEnumerable<CollectionEdge> edges, Dictionary<string, CollectionModChip> chips, Control canvas)
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
_canvas = canvas;
|
||||||
|
_edges.Clear();
|
||||||
|
_chips.Clear();
|
||||||
|
|
||||||
|
if (edges != null)
|
||||||
|
{
|
||||||
|
foreach (var edge in edges)
|
||||||
|
_edges.Add(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chips != null)
|
||||||
|
{
|
||||||
|
foreach (var pair in chips)
|
||||||
|
_chips[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Construct()
|
||||||
|
{
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore;
|
||||||
|
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
Connect("draw", Callable.From((Action)DrawAll));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawAll()
|
||||||
|
{
|
||||||
|
if (_canvas == null || !GodotObject.IsInstanceValid(_canvas))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var origin = _canvas.GetGlobalRect().Position;
|
||||||
|
|
||||||
|
foreach (var edge in _edges)
|
||||||
|
{
|
||||||
|
if (!_chips.TryGetValue(edge.FromModId, out var from) || !GodotObject.IsInstanceValid(from))
|
||||||
|
continue;
|
||||||
|
if (!_chips.TryGetValue(edge.ToModId, out var to) || !GodotObject.IsInstanceValid(to))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var a = from.GetGlobalRect().GetCenter() - origin;
|
||||||
|
var b = to.GetGlobalRect().GetCenter() - origin;
|
||||||
|
var points = BuildCurve(a, b);
|
||||||
|
var color = edge.Cross ? (edge.Active ? UiTokens.Danger : UiTokens.Warning) : (edge.Active ? UiTokens.Accent : UiTokens.LineStrong);
|
||||||
|
var width = edge.Cross ? (edge.Active ? 3f : 2f) : (edge.Active ? 2.5f : 1.5f);
|
||||||
|
|
||||||
|
if (edge.Cross)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < points.Count - 1; i++)
|
||||||
|
DrawDashedLine(points[i], points[i + 1], color, width, 5f);
|
||||||
|
DrawCircle(points[^1], 2.5f, color);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DrawPolyline(points.ToArray(), color, width, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Vector2> BuildCurve(Vector2 a, Vector2 b)
|
||||||
|
{
|
||||||
|
var dx = b.X - a.X;
|
||||||
|
var dy = b.Y - a.Y;
|
||||||
|
Vector2 c1, c2;
|
||||||
|
|
||||||
|
if (Mathf.Abs(dx) >= Mathf.Abs(dy))
|
||||||
|
{
|
||||||
|
c1 = new Vector2(a.X + dx * 0.4f, a.Y);
|
||||||
|
c2 = new Vector2(b.X - dx * 0.4f, b.Y);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
c1 = new Vector2(a.X, a.Y + dy * 0.4f);
|
||||||
|
c2 = new Vector2(b.X, b.Y - dy * 0.4f);
|
||||||
|
}
|
||||||
|
|
||||||
|
var points = new List<Vector2>();
|
||||||
|
const int steps = 24;
|
||||||
|
for (var i = 0; i <= steps; i++)
|
||||||
|
points.Add(Cubic(a, c1, c2, b, i / (float)steps));
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector2 Cubic(Vector2 a, Vector2 c1, Vector2 c2, Vector2 b, float t)
|
||||||
|
{
|
||||||
|
var u = 1f - t;
|
||||||
|
return u * u * u * a + 3f * u * u * t * c1 + 3f * u * t * t * c2 + t * t * t * b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Base.Lists;
|
||||||
|
using Cthangover.Mods.Interface.Theming;
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One contour of the collection scheme: the Godot counterpart of the
|
||||||
|
/// mockup's <c>.ce-group</c>. Renders a movable and resizable frame with the
|
||||||
|
/// mechanic role, name, state badge and description, hosts the draggable mod
|
||||||
|
/// chips on an absolute layer and draws the faint watermark label on the
|
||||||
|
/// background.
|
||||||
|
/// <para/>
|
||||||
|
/// The card owns no data beyond the resolved <see cref="ModCollectionGroup"/>
|
||||||
|
/// it displays: the header carries only the state pill and the optional
|
||||||
|
/// contour's on/off toggle (raising <see cref="Command"/> with "toggle"),
|
||||||
|
/// while editing commands live in the screen's inspector panel. Group and
|
||||||
|
/// chip coordinates are reported
|
||||||
|
/// through <see cref="Moved"/>, <see cref="Resized"/> and
|
||||||
|
/// <see cref="DragFinished"/> so the canvas can persist them through
|
||||||
|
/// <see cref="CollectionLayoutStore"/>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class CollectionGroupCard : ModWidget
|
||||||
|
{
|
||||||
|
/// <summary>Minimum frame width enforced while resizing.</summary>
|
||||||
|
public const int MinWidth = 300;
|
||||||
|
|
||||||
|
/// <summary>Minimum frame height enforced while resizing.</summary>
|
||||||
|
public const int MinHeight = 140;
|
||||||
|
|
||||||
|
/// <summary>Stable group key matching <see cref="ModCollectionGroup.Key"/>.</summary>
|
||||||
|
public string GroupKey { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Resolved group shown by this card.</summary>
|
||||||
|
public ModCollectionGroup Group { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Layer the canvas adds mod chips to; absolute coordinates start at the frame's top-left.</summary>
|
||||||
|
public Control ModsLayer { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Card-local Y where the free chip area begins: the bottom of the
|
||||||
|
/// header and description block plus a small gap. <see cref="CollectionModChip"/>
|
||||||
|
/// clamps vertical drags to this line, so a chip can never be parked on
|
||||||
|
/// top of the group title, its header buttons or its description. The
|
||||||
|
/// canvas keeps its own default for freshly packed slots.
|
||||||
|
/// </summary>
|
||||||
|
public float ModsTop
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
var block = _content.Position.Y + _desc.Position.Y + _desc.Size.Y;
|
||||||
|
return Mathf.Max(0f, block + UiTokens.Space2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raised on left click anywhere on the card that is not a chip or button.</summary>
|
||||||
|
public event Action<string> Selected;
|
||||||
|
|
||||||
|
/// <summary>Raised by the header buttons with a command id (see class summary).</summary>
|
||||||
|
public event Action<string, string> Command;
|
||||||
|
|
||||||
|
/// <summary>Raised while dragging the header with the desired canvas position.</summary>
|
||||||
|
public event Action<string, Vector2> Moved;
|
||||||
|
|
||||||
|
/// <summary>Raised while dragging the resize corner with the desired frame size.</summary>
|
||||||
|
public event Action<string, Vector2> Resized;
|
||||||
|
|
||||||
|
/// <summary>Raised once a header drag or resize ends, so layout can be flushed.</summary>
|
||||||
|
public event Action DragFinished;
|
||||||
|
|
||||||
|
private PanelContainer _panel;
|
||||||
|
private VBoxContainer _content;
|
||||||
|
private HBoxContainer _header;
|
||||||
|
private Label _role;
|
||||||
|
private Label _name;
|
||||||
|
private Label _desc;
|
||||||
|
private Label _watermark;
|
||||||
|
private PanelContainer _stateChip;
|
||||||
|
private Label _stateLabel;
|
||||||
|
private CheckButton _optional;
|
||||||
|
private CollectionResizeHandle _resize;
|
||||||
|
private StyleBoxFlat _style;
|
||||||
|
private Color _baseBorder = UiTokens.LineCard;
|
||||||
|
|
||||||
|
private bool _dragging;
|
||||||
|
private bool _resizing;
|
||||||
|
private bool _moved;
|
||||||
|
private bool _updatingOptional;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds the card to a resolved group. Must be called after the card is
|
||||||
|
/// added to the tree; safe to call again to refresh texts and states.
|
||||||
|
/// </summary>
|
||||||
|
public void Init(ModCollectionGroup group, string stateLabel, Color stateColor, bool selected)
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
|
||||||
|
Group = group;
|
||||||
|
GroupKey = group.Key;
|
||||||
|
_role.Text = string.IsNullOrEmpty(group.Role) ? string.Empty : group.Role.ToUpperInvariant();
|
||||||
|
_name.Text = group.Name;
|
||||||
|
_desc.Text = group.Description ?? string.Empty;
|
||||||
|
_watermark.Text = group.Name ?? string.Empty;
|
||||||
|
_stateLabel.Text = stateLabel;
|
||||||
|
_stateLabel.AddThemeColorOverride("font_color", stateColor);
|
||||||
|
_stateChip.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusXs, UiTokens.BadgeFill(stateColor), UiTokens.BadgeLine(stateColor)));
|
||||||
|
|
||||||
|
if (_optional != null)
|
||||||
|
{
|
||||||
|
_updatingOptional = true;
|
||||||
|
_optional.ButtonPressed = group.Selected;
|
||||||
|
_updatingOptional = false;
|
||||||
|
_optional.Visible = group.Optional;
|
||||||
|
}
|
||||||
|
|
||||||
|
_header.TooltipText = group.Description ?? string.Empty;
|
||||||
|
Modulate = new Color(1, 1, 1, group.Selected ? 1f : 0.55f);
|
||||||
|
SetSelected(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves and sizes the frame in canvas coordinates.</summary>
|
||||||
|
public void SetFrame(Rect2 rect)
|
||||||
|
{
|
||||||
|
Position = rect.Position;
|
||||||
|
Size = rect.Size;
|
||||||
|
_watermark.Size = new Vector2(Mathf.Max(0f, rect.Size.X - 24f), 52f);
|
||||||
|
_watermark.Position = new Vector2(8f, Mathf.Max(0f, rect.Size.Y - 56f));
|
||||||
|
_resize.Position = new Vector2(Mathf.Max(0f, rect.Size.X - CollectionResizeHandle.HandleSize), Mathf.Max(0f, rect.Size.Y - CollectionResizeHandle.HandleSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Highlights the frame as the current canvas/inspector selection.</summary>
|
||||||
|
public void SetSelected(bool selected)
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
_style.BorderColor = selected ? UiTokens.Accent : _baseBorder;
|
||||||
|
_style.BorderWidthTop = 1;
|
||||||
|
_style.BorderWidthRight = 1;
|
||||||
|
_style.BorderWidthBottom = 1;
|
||||||
|
_style.BorderWidthLeft = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates only the state pill, used when the plan changes the availability.</summary>
|
||||||
|
public void SetState(string stateLabel, Color stateColor)
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
_stateLabel.Text = stateLabel;
|
||||||
|
_stateLabel.AddThemeColorOverride("font_color", stateColor);
|
||||||
|
_stateChip.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusXs, UiTokens.BadgeFill(stateColor), UiTokens.BadgeLine(stateColor)));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Construct()
|
||||||
|
{
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore;
|
||||||
|
_baseBorder = UiTokens.LineCard;
|
||||||
|
|
||||||
|
_style = UiKit.Frame(12, new Color(UiTokens.Surface1, 0.92f), _baseBorder);
|
||||||
|
_panel = new PanelContainer { MouseFilter = MouseFilterEnum.Stop };
|
||||||
|
_panel.AddThemeStyleboxOverride("panel", _style);
|
||||||
|
_panel.GuiInput += OnPanelGuiInput;
|
||||||
|
AddChild(_panel);
|
||||||
|
_panel.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
ModsLayer = new Control { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
AddChild(ModsLayer);
|
||||||
|
ModsLayer.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
var contentHost = new Control { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
AddChild(contentHost);
|
||||||
|
contentHost.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
var margin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
margin.AddThemeConstantOverride("margin_left", UiTokens.Space6);
|
||||||
|
margin.AddThemeConstantOverride("margin_right", UiTokens.Space6);
|
||||||
|
margin.AddThemeConstantOverride("margin_top", UiTokens.Space5);
|
||||||
|
margin.AddThemeConstantOverride("margin_bottom", UiTokens.Space5);
|
||||||
|
contentHost.AddChild(margin);
|
||||||
|
margin.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
_content = new VBoxContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
_content.AddThemeConstantOverride("separation", 6);
|
||||||
|
margin.AddChild(_content);
|
||||||
|
|
||||||
|
_header = new HBoxContainer { MouseFilter = MouseFilterEnum.Stop };
|
||||||
|
_header.AddThemeConstantOverride("separation", UiTokens.Space3);
|
||||||
|
_header.GuiInput += OnHeaderGuiInput;
|
||||||
|
_content.AddChild(_header);
|
||||||
|
|
||||||
|
_role = UiKit.Label(string.Empty, UiTokens.FontMicro, UiTokens.AccentDim);
|
||||||
|
_role.Uppercase = true;
|
||||||
|
_role.SizeFlagsVertical = SizeFlags.ShrinkCenter;
|
||||||
|
_header.AddChild(_role);
|
||||||
|
|
||||||
|
_name = UiKit.Label(string.Empty, UiTokens.FontLead, UiTokens.InkBright);
|
||||||
|
_name.SizeFlagsVertical = SizeFlags.ShrinkCenter;
|
||||||
|
_name.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
_name.ClipText = true;
|
||||||
|
_header.AddChild(_name);
|
||||||
|
|
||||||
|
_stateLabel = UiKit.Label(string.Empty, UiTokens.FontMicro, UiTokens.Success);
|
||||||
|
_stateLabel.SizeFlagsVertical = SizeFlags.ShrinkCenter;
|
||||||
|
_stateChip = new PanelContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
_stateChip.AddChild(_stateLabel);
|
||||||
|
_header.AddChild(_stateChip);
|
||||||
|
|
||||||
|
// Commands (add mod, rename, replace, remove) live only in the
|
||||||
|
// right-hand inspector — the header stays a compact title row with
|
||||||
|
// the state pill and, for optional contours, the on/off toggle.
|
||||||
|
|
||||||
|
_optional = new CheckButton { Visible = false };
|
||||||
|
_optional.Toggled += OnOptionalToggled;
|
||||||
|
_header.AddChild(_optional);
|
||||||
|
|
||||||
|
_desc = UiKit.Label(string.Empty, UiTokens.FontSmall, UiTokens.Muted);
|
||||||
|
_desc.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||||
|
_desc.MaxLinesVisible = 3;
|
||||||
|
_content.AddChild(_desc);
|
||||||
|
|
||||||
|
_watermark = UiKit.Label(string.Empty, 42, new Color(1f, 1f, 1f, 0.04f));
|
||||||
|
_watermark.Uppercase = true;
|
||||||
|
_watermark.ClipText = true;
|
||||||
|
_watermark.HorizontalAlignment = HorizontalAlignment.Right;
|
||||||
|
AddChild(_watermark);
|
||||||
|
|
||||||
|
_resize = new CollectionResizeHandle();
|
||||||
|
_resize.GuiInput += OnResizeGuiInput;
|
||||||
|
AddChild(_resize);
|
||||||
|
_resize.EnsureConstructed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOptionalToggled(bool pressed)
|
||||||
|
{
|
||||||
|
if (_updatingOptional)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Command?.Invoke(GroupKey, "toggle");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPanelGuiInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is InputEventMouseButton button && button.ButtonIndex == MouseButton.Left && button.Pressed)
|
||||||
|
{
|
||||||
|
Selected?.Invoke(GroupKey);
|
||||||
|
AcceptEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHeaderGuiInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is InputEventMouseButton button && button.ButtonIndex == MouseButton.Left)
|
||||||
|
{
|
||||||
|
if (button.Pressed)
|
||||||
|
{
|
||||||
|
_dragging = true;
|
||||||
|
_moved = false;
|
||||||
|
Selected?.Invoke(GroupKey);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_dragging = false;
|
||||||
|
if (_moved)
|
||||||
|
DragFinished?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
AcceptEvent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (@event is InputEventMouseMotion motion && _dragging)
|
||||||
|
{
|
||||||
|
var desired = Position + motion.Relative;
|
||||||
|
var clamped = new Vector2(Mathf.Max(0f, desired.X), Mathf.Max(0f, desired.Y));
|
||||||
|
if (clamped.DistanceTo(Position) > 0.5f)
|
||||||
|
_moved = true;
|
||||||
|
|
||||||
|
Position = clamped;
|
||||||
|
Moved?.Invoke(GroupKey, clamped);
|
||||||
|
AcceptEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnResizeGuiInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is InputEventMouseButton button && button.ButtonIndex == MouseButton.Left)
|
||||||
|
{
|
||||||
|
if (button.Pressed)
|
||||||
|
{
|
||||||
|
_resizing = true;
|
||||||
|
_moved = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_resizing = false;
|
||||||
|
if (_moved)
|
||||||
|
DragFinished?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
AcceptEvent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (@event is InputEventMouseMotion motion && _resizing)
|
||||||
|
{
|
||||||
|
var size = new Vector2(
|
||||||
|
Mathf.Max(MinWidth, Size.X + motion.Relative.X),
|
||||||
|
Mathf.Max(MinHeight, Size.Y + motion.Relative.Y));
|
||||||
|
if (size.DistanceTo(Size) > 0.5f)
|
||||||
|
_moved = true;
|
||||||
|
|
||||||
|
SetFrame(new Rect2(Position, size));
|
||||||
|
Resized?.Invoke(GroupKey, size);
|
||||||
|
AcceptEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Destruct()
|
||||||
|
{
|
||||||
|
_optional.Toggled -= OnOptionalToggled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Base.Lists;
|
||||||
|
using Cthangover.Mods.Interface.Theming;
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Draggable chip of one mod inside a scheme group, the Godot counterpart of
|
||||||
|
/// the mockup's <c>.ce-mod</c>. Built fully in code and used through the
|
||||||
|
/// <see cref="ModWidget"/> contract: the canvas adds it to the mods layer
|
||||||
|
/// first and only then calls <see cref="Init"/>, which builds the visuals
|
||||||
|
/// under a valid tree.
|
||||||
|
/// <para/>
|
||||||
|
/// The chip is click-selectable and freely draggable inside its group: the
|
||||||
|
/// drag pattern keeps the pointer over the panel because the control follows
|
||||||
|
/// the mouse by the same delta (the same trick the map editor handles use).
|
||||||
|
/// It owns no data — the canvas persists the resulting position into
|
||||||
|
/// <see cref="Cthangover.Core.Mods.ModCollectionLayout"/> and decides what a
|
||||||
|
/// click means.
|
||||||
|
/// </summary>
|
||||||
|
public partial class CollectionModChip : ModWidget
|
||||||
|
{
|
||||||
|
/// <summary>Fixed chip width in canvas pixels; the canvas layout math depends on it.</summary>
|
||||||
|
public const int ChipWidth = 210;
|
||||||
|
|
||||||
|
/// <summary>Fixed chip height in canvas pixels.</summary>
|
||||||
|
public const int ChipHeight = 94;
|
||||||
|
|
||||||
|
/// <summary>Canonical mod id this chip represents.</summary>
|
||||||
|
public string ModId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Raised on left click; the canvas turns it into a selection.</summary>
|
||||||
|
public event Action<string> Selected;
|
||||||
|
|
||||||
|
/// <summary>Raised while dragging with the desired position inside the group's mods layer.</summary>
|
||||||
|
public event Action<string, Vector2> Moved;
|
||||||
|
|
||||||
|
/// <summary>Raised when a drag ends, after the last <see cref="Moved"/>.</summary>
|
||||||
|
public event Action DragFinished;
|
||||||
|
|
||||||
|
private PanelContainer _panel;
|
||||||
|
private Label _role;
|
||||||
|
private Label _name;
|
||||||
|
private Label _meta;
|
||||||
|
private ColorRect _dot;
|
||||||
|
private StyleBoxFlat _style;
|
||||||
|
private Color _stateColor = UiTokens.Success;
|
||||||
|
|
||||||
|
private bool _dragging;
|
||||||
|
private bool _moved;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds the chip to a resolved mod and paints it. Must be called after
|
||||||
|
/// the chip is added to the tree; safe to call again to refresh the
|
||||||
|
/// status without rebuilding the node.
|
||||||
|
/// </summary>
|
||||||
|
public void Init(string modId, string role, string title, string version, string stateLabel, Color stateColor, string tooltip)
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
|
||||||
|
ModId = modId;
|
||||||
|
_stateColor = stateColor;
|
||||||
|
_role.Text = string.IsNullOrEmpty(role) ? string.Empty : role.ToUpperInvariant();
|
||||||
|
_name.Text = title;
|
||||||
|
_meta.Text = string.IsNullOrEmpty(version) ? stateLabel : $"{version} · {stateLabel}";
|
||||||
|
_dot.Color = stateColor;
|
||||||
|
_style.BorderColor = stateColor;
|
||||||
|
_panel.TooltipText = tooltip ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Highlights the chip as the current canvas/inspector selection.</summary>
|
||||||
|
public void SetSelected(bool selected)
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
_style.BorderColor = selected ? UiTokens.Accent : _stateColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Construct()
|
||||||
|
{
|
||||||
|
MouseFilter = MouseFilterEnum.Ignore;
|
||||||
|
CustomMinimumSize = new Vector2(ChipWidth, ChipHeight);
|
||||||
|
Size = new Vector2(ChipWidth, ChipHeight);
|
||||||
|
|
||||||
|
_style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface1, UiTokens.LineCard);
|
||||||
|
_style.BorderWidthLeft = 3;
|
||||||
|
|
||||||
|
_panel = new PanelContainer { MouseFilter = MouseFilterEnum.Stop };
|
||||||
|
_panel.AddThemeStyleboxOverride("panel", _style);
|
||||||
|
_panel.GuiInput += OnGuiInput;
|
||||||
|
AddChild(_panel);
|
||||||
|
_panel.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||||
|
|
||||||
|
var margin = new MarginContainer();
|
||||||
|
margin.AddThemeConstantOverride("margin_left", UiTokens.Space3);
|
||||||
|
margin.AddThemeConstantOverride("margin_right", 18);
|
||||||
|
margin.AddThemeConstantOverride("margin_top", 6);
|
||||||
|
margin.AddThemeConstantOverride("margin_bottom", 7);
|
||||||
|
_panel.AddChild(margin);
|
||||||
|
|
||||||
|
var column = new VBoxContainer();
|
||||||
|
column.AddThemeConstantOverride("separation", 3);
|
||||||
|
margin.AddChild(column);
|
||||||
|
|
||||||
|
_role = UiKit.Label(string.Empty, UiTokens.FontMicro, UiTokens.Faint);
|
||||||
|
_role.Uppercase = true;
|
||||||
|
_role.ClipText = true;
|
||||||
|
column.AddChild(_role);
|
||||||
|
|
||||||
|
var row = new HBoxContainer();
|
||||||
|
row.AddThemeConstantOverride("separation", 6);
|
||||||
|
_dot = new ColorRect { Color = UiTokens.Success, CustomMinimumSize = new Vector2(7, 7), MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
_dot.SizeFlagsVertical = SizeFlags.ShrinkBegin;
|
||||||
|
row.AddChild(_dot);
|
||||||
|
|
||||||
|
_name = UiKit.Label(string.Empty, UiTokens.FontBodySmall, UiTokens.InkBright);
|
||||||
|
_name.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||||
|
_name.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
_name.MaxLinesVisible = 3;
|
||||||
|
row.AddChild(_name);
|
||||||
|
column.AddChild(row);
|
||||||
|
|
||||||
|
_meta = UiKit.Label(string.Empty, UiTokens.FontTiny, UiTokens.Faint);
|
||||||
|
_meta.SizeFlagsVertical = SizeFlags.ExpandFill;
|
||||||
|
_meta.VerticalAlignment = VerticalAlignment.Bottom;
|
||||||
|
column.AddChild(_meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGuiInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is InputEventMouseButton button && button.ButtonIndex == MouseButton.Left)
|
||||||
|
{
|
||||||
|
if (button.Pressed)
|
||||||
|
{
|
||||||
|
_dragging = true;
|
||||||
|
_moved = false;
|
||||||
|
Selected?.Invoke(ModId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_dragging = false;
|
||||||
|
if (_moved)
|
||||||
|
DragFinished?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
AcceptEvent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (@event is InputEventMouseMotion motion && _dragging)
|
||||||
|
{
|
||||||
|
var layer = GetParent<Control>();
|
||||||
|
var card = layer?.GetParent<Control>() as CollectionGroupCard;
|
||||||
|
var bounds = layer?.Size ?? Vector2.Zero;
|
||||||
|
if (card != null)
|
||||||
|
bounds = new Vector2(Mathf.Max(bounds.X, card.Size.X), Mathf.Max(bounds.Y, card.Size.Y));
|
||||||
|
|
||||||
|
var max = new Vector2(
|
||||||
|
Mathf.Max(0f, bounds.X - Size.X - 2f),
|
||||||
|
Mathf.Max(0f, bounds.Y - Size.Y - 2f));
|
||||||
|
var minY = Mathf.Min(card?.ModsTop ?? 0f, max.Y);
|
||||||
|
var desired = Position + motion.Relative;
|
||||||
|
var clamped = new Vector2(Mathf.Clamp(desired.X, 0f, max.X), Mathf.Clamp(desired.Y, minY, max.Y));
|
||||||
|
|
||||||
|
if (clamped.DistanceTo(Position) > 0.5f)
|
||||||
|
_moved = true;
|
||||||
|
|
||||||
|
Position = clamped;
|
||||||
|
Moved?.Invoke(ModId, clamped);
|
||||||
|
AcceptEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Base.Lists;
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Visible resize grip in the bottom-right corner of a scheme group. The
|
||||||
|
/// mockup showed three diagonal stripes there; in Godot they are painted
|
||||||
|
/// through the <c>"draw"</c> signal because virtual overrides are unreliable
|
||||||
|
/// in mod assemblies, and the control itself only reports raw GuiInput to its
|
||||||
|
/// <see cref="CollectionGroupCard"/> owner.
|
||||||
|
/// </summary>
|
||||||
|
public partial class CollectionResizeHandle : ModWidget
|
||||||
|
{
|
||||||
|
/// <summary>Grip side in pixels; the card positions this control at its bottom-right corner.</summary>
|
||||||
|
public const int HandleSize = 18;
|
||||||
|
|
||||||
|
public CollectionResizeHandle()
|
||||||
|
{
|
||||||
|
MouseFilter = MouseFilterEnum.Stop;
|
||||||
|
CustomMinimumSize = new Vector2(HandleSize, HandleSize);
|
||||||
|
Size = new Vector2(HandleSize, HandleSize);
|
||||||
|
MouseDefaultCursorShape = CursorShape.Fdiagsize;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Construct()
|
||||||
|
{
|
||||||
|
Connect("draw", Callable.From((Action)DrawStripes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawStripes()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
var offset = 5f + i * 5f;
|
||||||
|
DrawLine(
|
||||||
|
new Vector2(Size.X - offset, Size.Y),
|
||||||
|
new Vector2(Size.X, Size.Y - offset),
|
||||||
|
UiTokens.LineStrong,
|
||||||
|
1.5f,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Base.Lists;
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Free-form scheme canvas: the whole game on one "whatman", exactly like
|
||||||
|
/// the approved mockup. Renders one <see cref="CollectionGroupCard"/> per
|
||||||
|
/// resolved <see cref="ModCollectionGroup"/> with its draggable
|
||||||
|
/// <see cref="CollectionModChip"/>s and paints dependency lines through
|
||||||
|
/// <see cref="CollectionEdgeLayer"/>.
|
||||||
|
/// <para/>
|
||||||
|
/// The canvas owns only presentation and the layout dictionary: it computes
|
||||||
|
/// a packed default arrangement for groups the player has not placed yet
|
||||||
|
/// (new groups appear below the existing ones so nothing overlaps), clamps
|
||||||
|
/// drags to the canvas, persists nothing itself and reports edits upward via
|
||||||
|
/// <see cref="LayoutChanged"/>, <see cref="GroupSelected"/>,
|
||||||
|
/// <see cref="ModSelected"/> and <see cref="GroupCommand"/>. The screen
|
||||||
|
/// decides what a command means for the current draft and when to save.
|
||||||
|
/// </summary>
|
||||||
|
public partial class CollectionSchemeCanvas : ModWidget
|
||||||
|
{
|
||||||
|
private const float ColumnWidth = 460f;
|
||||||
|
private const float GapX = 18f;
|
||||||
|
private const float GapY = 16f;
|
||||||
|
private const float Margin = 10f;
|
||||||
|
private const float ModX0 = 14f;
|
||||||
|
private const float ModY0 = 76f;
|
||||||
|
private const float ModGapX = 8f;
|
||||||
|
private const float ModGapY = 8f;
|
||||||
|
private const int ModColumns = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Floor for the scrollable workspace. A draggable canvas is useless if
|
||||||
|
/// the scrollbars disappear once the packed layout happens to fit the
|
||||||
|
/// window, so the workspace is always at least this large; content that
|
||||||
|
/// grows beyond it extends the area further.
|
||||||
|
/// </summary>
|
||||||
|
private const float MinCanvasWidth = 3200f;
|
||||||
|
|
||||||
|
/// <summary>See <see cref="MinCanvasWidth"/>.</summary>
|
||||||
|
private const float MinCanvasHeight = 2000f;
|
||||||
|
|
||||||
|
/// <summary>Raised when a group card is clicked; payload is the group key.</summary>
|
||||||
|
public event Action<string> GroupSelected;
|
||||||
|
|
||||||
|
/// <summary>Raised when a mod chip is clicked; payload is the mod id.</summary>
|
||||||
|
public event Action<string> ModSelected;
|
||||||
|
|
||||||
|
/// <summary>Raised by group header buttons; payload is the group and the command id.</summary>
|
||||||
|
public event Action<ModCollectionGroup, string> GroupCommand;
|
||||||
|
|
||||||
|
/// <summary>Raised after any drag or resize ends, so the layout can be flushed to the store.</summary>
|
||||||
|
public event Action LayoutChanged;
|
||||||
|
|
||||||
|
/// <summary>Whether dependency lines are painted; toggled from the header.</summary>
|
||||||
|
public bool ShowEdges { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Currently highlighted group key, if any.</summary>
|
||||||
|
public string SelectedGroupKey { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Currently highlighted mod id, if any.</summary>
|
||||||
|
public string SelectedModId { get; private set; }
|
||||||
|
|
||||||
|
private CollectionEdgeLayer _edges;
|
||||||
|
private ModCollectionGraph _graph;
|
||||||
|
private ModCollectionLayout _layout = new();
|
||||||
|
private readonly Dictionary<string, CollectionGroupCard> _cards = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, CollectionModChip> _chips = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, string> _chipGroups = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private bool _panning;
|
||||||
|
private Vector2 _panStartMouse;
|
||||||
|
private Vector2 _panStartScroll;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebuilds the canvas for a resolved graph and its saved layout. The
|
||||||
|
/// layout object is mutated in place (new groups/mods get default
|
||||||
|
/// coordinates); pass the instance owned by the screen so the next
|
||||||
|
/// <see cref="LayoutChanged"/> can persist it.
|
||||||
|
/// </summary>
|
||||||
|
public void Build(ModCollectionGraph graph, ModCollectionLayout layout)
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
|
||||||
|
_graph = graph;
|
||||||
|
_layout = layout ?? new ModCollectionLayout();
|
||||||
|
_layout.Groups ??= new Dictionary<string, ModCollectionLayoutRect>();
|
||||||
|
_layout.Mods ??= new Dictionary<string, ModCollectionLayoutPoint>();
|
||||||
|
|
||||||
|
foreach (var child in GetChildren())
|
||||||
|
{
|
||||||
|
if (child != _edges)
|
||||||
|
child.QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
_cards.Clear();
|
||||||
|
_chips.Clear();
|
||||||
|
_chipGroups.Clear();
|
||||||
|
|
||||||
|
if (graph != null && graph.Groups.Count > 0)
|
||||||
|
{
|
||||||
|
EnsureLayout();
|
||||||
|
BuildCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
_edges.SetEdges(null, _chips, this);
|
||||||
|
MoveChild(_edges, GetChildCount() - 1);
|
||||||
|
_edges.Visible = ShowEdges;
|
||||||
|
UpdateCanvasSize();
|
||||||
|
RebuildEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Highlights one group and/or one mod; pass nulls to clear.</summary>
|
||||||
|
public void SetSelection(string groupKey, string modId)
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
SelectedGroupKey = groupKey;
|
||||||
|
SelectedModId = modId;
|
||||||
|
|
||||||
|
foreach (var pair in _cards)
|
||||||
|
pair.Value.SetSelected(string.Equals(pair.Key, groupKey, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
foreach (var pair in _chips)
|
||||||
|
pair.Value.SetSelected(string.Equals(pair.Key, modId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
RebuildEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shows or hides dependency lines.</summary>
|
||||||
|
public void SetEdgesVisible(bool visible)
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
ShowEdges = visible;
|
||||||
|
_edges.Visible = visible;
|
||||||
|
if (visible)
|
||||||
|
_edges.QueueRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Finds the group key owning a rendered mod chip, for inspector navigation.</summary>
|
||||||
|
public string GetGroupKeyOfMod(string modId)
|
||||||
|
=> modId != null && _chipGroups.TryGetValue(modId, out var key) ? key : null;
|
||||||
|
|
||||||
|
protected override void Construct()
|
||||||
|
{
|
||||||
|
MouseFilter = MouseFilterEnum.Stop;
|
||||||
|
GuiInput += OnCanvasGuiInput;
|
||||||
|
_edges = new CollectionEdgeLayer();
|
||||||
|
AddChild(_edges);
|
||||||
|
_edges.EnsureConstructed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildCards()
|
||||||
|
{
|
||||||
|
foreach (var group in _graph.Groups)
|
||||||
|
{
|
||||||
|
var rect = _layout.Groups[group.Key];
|
||||||
|
var card = new CollectionGroupCard();
|
||||||
|
AddChild(card);
|
||||||
|
|
||||||
|
var summary = Summarize(group);
|
||||||
|
card.Init(group, summary.Label, summary.Color, string.Equals(group.Key, SelectedGroupKey, StringComparison.OrdinalIgnoreCase));
|
||||||
|
card.SetFrame(new Rect2(rect.X ?? 0f, rect.Y ?? 0f, rect.W, rect.H));
|
||||||
|
|
||||||
|
card.Selected += OnCardSelected;
|
||||||
|
card.Command += OnCardCommand;
|
||||||
|
card.Moved += OnCardMoved;
|
||||||
|
card.Resized += OnCardResized;
|
||||||
|
card.DragFinished += OnDragFinished;
|
||||||
|
_cards[group.Key] = card;
|
||||||
|
|
||||||
|
foreach (var mod in group.Mods)
|
||||||
|
{
|
||||||
|
var key = group.Key + "|" + mod.ModId;
|
||||||
|
var point = _layout.Mods.TryGetValue(key, out var stored) ? stored : null;
|
||||||
|
var chip = new CollectionModChip();
|
||||||
|
card.ModsLayer.AddChild(chip);
|
||||||
|
|
||||||
|
var node = FindNode(mod.ModId);
|
||||||
|
chip.Init(
|
||||||
|
mod.ModId,
|
||||||
|
mod.Role,
|
||||||
|
node?.Title ?? mod.ModId,
|
||||||
|
VersionOf(node),
|
||||||
|
StateLabel(node),
|
||||||
|
StateColor(node),
|
||||||
|
BuildModTooltip(mod, node));
|
||||||
|
chip.Position = new Vector2(point?.X ?? ModX0, point?.Y ?? ModY0);
|
||||||
|
chip.Selected += OnChipSelected;
|
||||||
|
chip.Moved += OnChipMoved;
|
||||||
|
chip.DragFinished += OnDragFinished;
|
||||||
|
|
||||||
|
if (!_chips.ContainsKey(mod.ModId))
|
||||||
|
{
|
||||||
|
_chips[mod.ModId] = chip;
|
||||||
|
_chipGroups[mod.ModId] = group.Key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Packs groups the player has not placed yet: a fresh canvas uses a
|
||||||
|
/// masonry layout, an already arranged canvas appends new groups below
|
||||||
|
/// everything so an added mechanic never lands on top of the work.
|
||||||
|
/// </summary>
|
||||||
|
private void EnsureLayout()
|
||||||
|
{
|
||||||
|
var unplaced = _graph.Groups
|
||||||
|
.Where(g => !_layout.Groups.TryGetValue(g.Key, out var rect) || rect == null || !rect.X.HasValue || !rect.Y.HasValue)
|
||||||
|
.Select(g => g.Key)
|
||||||
|
.ToList();
|
||||||
|
var hasPlaced = unplaced.Count < _graph.Groups.Count;
|
||||||
|
|
||||||
|
foreach (var group in _graph.Groups)
|
||||||
|
{
|
||||||
|
if (!_layout.Groups.TryGetValue(group.Key, out var rect) || rect == null)
|
||||||
|
{
|
||||||
|
rect = new ModCollectionLayoutRect();
|
||||||
|
_layout.Groups[group.Key] = rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
var used = new List<ModCollectionLayoutPoint>();
|
||||||
|
foreach (var mod in group.Mods)
|
||||||
|
{
|
||||||
|
var key = group.Key + "|" + mod.ModId;
|
||||||
|
if (!_layout.Mods.TryGetValue(key, out var point) || point == null)
|
||||||
|
{
|
||||||
|
point = NextFreeModSlot(used);
|
||||||
|
_layout.Mods[key] = point;
|
||||||
|
}
|
||||||
|
used.Add(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rect.W <= 0f || rect.H <= 0f)
|
||||||
|
{
|
||||||
|
var maxX = used.Count > 0 ? used.Max(p => p.X + CollectionModChip.ChipWidth) : 0f;
|
||||||
|
var maxY = used.Count > 0 ? used.Max(p => p.Y + CollectionModChip.ChipHeight) : 0f;
|
||||||
|
rect.W = Mathf.Max(CollectionGroupCard.MinWidth, maxX + 16f);
|
||||||
|
rect.H = Mathf.Max(CollectionGroupCard.MinHeight, maxY + 16f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaults = ComputeDefaultLayout();
|
||||||
|
var bottom = Margin;
|
||||||
|
foreach (var group in _graph.Groups)
|
||||||
|
{
|
||||||
|
var rect = _layout.Groups[group.Key];
|
||||||
|
if (rect.X.HasValue && rect.Y.HasValue && !unplaced.Contains(group.Key))
|
||||||
|
bottom = Mathf.Max(bottom, rect.Y.Value + rect.H + GapY);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in _graph.Groups)
|
||||||
|
{
|
||||||
|
var rect = _layout.Groups[group.Key];
|
||||||
|
if (!rect.X.HasValue || !rect.Y.HasValue)
|
||||||
|
{
|
||||||
|
if (!hasPlaced && defaults.TryGetValue(group.Key, out var packed))
|
||||||
|
{
|
||||||
|
rect.X = packed.X;
|
||||||
|
rect.Y = packed.Y;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rect.X = Margin;
|
||||||
|
rect.Y = bottom;
|
||||||
|
bottom += rect.H + GapY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, Vector2> ComputeDefaultLayout()
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, Vector2>();
|
||||||
|
var groups = _graph?.Groups ?? new List<ModCollectionGroup>();
|
||||||
|
if (groups.Count == 0)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var maxCols = Mathf.Min(3, Mathf.Max(1, Mathf.CeilToInt(groups.Count / 2f)));
|
||||||
|
var heights = new float[maxCols];
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var height = _layout.Groups[group.Key].H;
|
||||||
|
var column = 0;
|
||||||
|
for (var i = 1; i < maxCols; i++)
|
||||||
|
{
|
||||||
|
if (heights[i] < heights[column])
|
||||||
|
column = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[group.Key] = new Vector2(Margin + column * (ColumnWidth + GapX), Margin + heights[column]);
|
||||||
|
heights[column] += height + GapY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ModCollectionLayoutPoint NextFreeModSlot(List<ModCollectionLayoutPoint> used)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 400; i++)
|
||||||
|
{
|
||||||
|
var column = i % ModColumns;
|
||||||
|
var row = i / ModColumns;
|
||||||
|
var point = new ModCollectionLayoutPoint
|
||||||
|
{
|
||||||
|
X = ModX0 + column * (CollectionModChip.ChipWidth + ModGapX),
|
||||||
|
Y = ModY0 + row * (CollectionModChip.ChipHeight + ModGapY),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!used.Any(u => Mathf.Abs(u.X - point.X) < CollectionModChip.ChipWidth * 0.9f
|
||||||
|
&& Mathf.Abs(u.Y - point.Y) < CollectionModChip.ChipHeight * 0.9f))
|
||||||
|
{
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bottom = used.Count > 0 ? used.Max(p => p.Y) : ModY0;
|
||||||
|
return new ModCollectionLayoutPoint { X = ModX0, Y = bottom + CollectionModChip.ChipHeight + ModGapY };
|
||||||
|
}
|
||||||
|
|
||||||
|
private ModCollectionNode FindNode(string modId)
|
||||||
|
{
|
||||||
|
if (_graph?.Nodes == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
foreach (var node in _graph.Nodes)
|
||||||
|
{
|
||||||
|
if (string.Equals(node.ModId, modId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string VersionOf(ModCollectionNode node)
|
||||||
|
{
|
||||||
|
var version = node?.InstalledVersion;
|
||||||
|
return string.IsNullOrEmpty(version) ? null : "v" + version;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StateLabel(ModCollectionNode node)
|
||||||
|
=> node == null ? CollectionStateNames.Translate(ModCollectionNodeState.Missing) : CollectionStateNames.Translate(node.State);
|
||||||
|
|
||||||
|
private static Color StateColor(ModCollectionNode node)
|
||||||
|
=> node == null ? CollectionStateNames.Color(ModCollectionNodeState.Missing) : CollectionStateNames.Color(node.State);
|
||||||
|
|
||||||
|
private static string BuildModTooltip(ModCollectionModRef mod, ModCollectionNode node)
|
||||||
|
{
|
||||||
|
var label = node?.Title ?? mod.ModId;
|
||||||
|
return $"{label} · {mod.ModId} · {StateLabel(node)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Worst-state summary shown on the group card: missing beats mismatch beats downloads beats disable.</summary>
|
||||||
|
private (string Label, Color Color) Summarize(ModCollectionGroup group)
|
||||||
|
{
|
||||||
|
if (!group.Selected)
|
||||||
|
return (TranslationServer.Translate("mod_manager/collection_group_off"), UiTokens.Warning);
|
||||||
|
|
||||||
|
var states = new List<ModCollectionNodeState>();
|
||||||
|
foreach (var mod in group.Mods)
|
||||||
|
{
|
||||||
|
var node = FindNode(mod.ModId);
|
||||||
|
states.Add(node?.State ?? ModCollectionNodeState.Missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (states.Count == 0)
|
||||||
|
return (TranslationServer.Translate("mod_manager/collection_group_empty"), UiTokens.Info);
|
||||||
|
|
||||||
|
if (states.Any(s => s == ModCollectionNodeState.Missing))
|
||||||
|
return (CountLabel(ModCollectionNodeState.Missing, states), UiTokens.Danger);
|
||||||
|
if (states.Any(s => s == ModCollectionNodeState.VersionMismatch))
|
||||||
|
return (CountLabel(ModCollectionNodeState.VersionMismatch, states), UiTokens.Warning);
|
||||||
|
if (states.Any(s => s == ModCollectionNodeState.InCatalog))
|
||||||
|
return (CountLabel(ModCollectionNodeState.InCatalog, states), UiTokens.Info);
|
||||||
|
if (states.Any(s => s == ModCollectionNodeState.Disabled))
|
||||||
|
return (CountLabel(ModCollectionNodeState.Disabled, states), UiTokens.Warning);
|
||||||
|
|
||||||
|
return (TranslationServer.Translate("mod_manager/collection_group_ready"), UiTokens.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CountLabel(ModCollectionNodeState state, List<ModCollectionNodeState> states)
|
||||||
|
=> $"{CollectionStateNames.Translate(state)}: {states.Count(s => s == state)}";
|
||||||
|
|
||||||
|
private void OnCardSelected(string groupKey)
|
||||||
|
{
|
||||||
|
SelectedGroupKey = groupKey;
|
||||||
|
SelectedModId = null;
|
||||||
|
GroupSelected?.Invoke(groupKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnChipSelected(string modId)
|
||||||
|
{
|
||||||
|
SelectedModId = modId;
|
||||||
|
SelectedGroupKey = null;
|
||||||
|
ModSelected?.Invoke(modId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCardCommand(string groupKey, string command)
|
||||||
|
{
|
||||||
|
if (_cards.TryGetValue(groupKey, out var card))
|
||||||
|
GroupCommand?.Invoke(card.Group, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCardMoved(string groupKey, Vector2 position)
|
||||||
|
{
|
||||||
|
var rect = Rect(groupKey);
|
||||||
|
rect.X = position.X;
|
||||||
|
rect.Y = position.Y;
|
||||||
|
UpdateCanvasSize();
|
||||||
|
RebuildEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCardResized(string groupKey, Vector2 size)
|
||||||
|
{
|
||||||
|
var rect = Rect(groupKey);
|
||||||
|
rect.W = size.X;
|
||||||
|
rect.H = size.Y;
|
||||||
|
UpdateCanvasSize();
|
||||||
|
RebuildEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnChipMoved(string modId, Vector2 position)
|
||||||
|
{
|
||||||
|
if (!_chipGroups.TryGetValue(modId, out var groupKey))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var key = groupKey + "|" + modId;
|
||||||
|
if (!_layout.Mods.TryGetValue(key, out var point) || point == null)
|
||||||
|
{
|
||||||
|
point = new ModCollectionLayoutPoint();
|
||||||
|
_layout.Mods[key] = point;
|
||||||
|
}
|
||||||
|
|
||||||
|
point.X = position.X;
|
||||||
|
point.Y = position.Y;
|
||||||
|
RebuildEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDragFinished() => LayoutChanged?.Invoke();
|
||||||
|
|
||||||
|
private ModCollectionLayoutRect Rect(string groupKey)
|
||||||
|
{
|
||||||
|
if (!_layout.Groups.TryGetValue(groupKey, out var rect) || rect == null)
|
||||||
|
{
|
||||||
|
rect = new ModCollectionLayoutRect();
|
||||||
|
_layout.Groups[groupKey] = rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCanvasSize()
|
||||||
|
{
|
||||||
|
var maxX = 0f;
|
||||||
|
var maxY = 0f;
|
||||||
|
foreach (var card in _cards.Values)
|
||||||
|
{
|
||||||
|
if (!GodotObject.IsInstanceValid(card))
|
||||||
|
continue;
|
||||||
|
maxX = Mathf.Max(maxX, card.Position.X + card.Size.X);
|
||||||
|
maxY = Mathf.Max(maxY, card.Position.Y + card.Size.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
var size = new Vector2(
|
||||||
|
Mathf.Max(maxX + GapX + 160f, MinCanvasWidth),
|
||||||
|
Mathf.Max(maxY + GapY + 120f, MinCanvasHeight));
|
||||||
|
// Drives the canvas rect through the surrounding ScrollContainer.
|
||||||
|
// _edges carries FullRect anchors (CollectionEdgeLayer.Construct),
|
||||||
|
// so it follows this resize automatically; assigning its Size
|
||||||
|
// directly would be overridden and logs an anchors warning.
|
||||||
|
CustomMinimumSize = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildEdges()
|
||||||
|
{
|
||||||
|
var edges = new List<CollectionEdge>();
|
||||||
|
if (_graph?.Edges != null)
|
||||||
|
{
|
||||||
|
foreach (var edge in _graph.Edges)
|
||||||
|
{
|
||||||
|
if (!_chips.TryGetValue(edge.FromModId, out _) || !_chips.TryGetValue(edge.ToModId, out _))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var fromGroup = GetGroupKeyOfMod(edge.FromModId);
|
||||||
|
var toGroup = GetGroupKeyOfMod(edge.ToModId);
|
||||||
|
edges.Add(new CollectionEdge
|
||||||
|
{
|
||||||
|
FromModId = edge.FromModId,
|
||||||
|
ToModId = edge.ToModId,
|
||||||
|
Cross = !string.Equals(fromGroup, toGroup, StringComparison.OrdinalIgnoreCase),
|
||||||
|
Active = string.Equals(edge.FromModId, SelectedModId, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(edge.ToModId, SelectedModId, StringComparison.OrdinalIgnoreCase),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_edges.SetEdges(edges, _chips, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pans the surrounding ScrollContainer by dragging the empty canvas
|
||||||
|
/// with the left button held. The canvas floor is the only control that
|
||||||
|
/// receives this event: group cards, chips, headers and buttons consume
|
||||||
|
/// their presses first (Godot then keeps them as the mouse focus until
|
||||||
|
/// release), so their own drags stay untouched. Positions are anchored
|
||||||
|
/// to the press point instead of accumulated deltas, so a scroll edge
|
||||||
|
/// does not build up drift once the pointer returns.
|
||||||
|
/// </summary>
|
||||||
|
private void OnCanvasGuiInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is InputEventMouseButton button && button.ButtonIndex == MouseButton.Left)
|
||||||
|
{
|
||||||
|
if (button.Pressed)
|
||||||
|
{
|
||||||
|
var scroll = GetParent<ScrollContainer>();
|
||||||
|
if (scroll == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_panning = true;
|
||||||
|
_panStartMouse = button.GlobalPosition;
|
||||||
|
_panStartScroll = new Vector2(scroll.ScrollHorizontal, scroll.ScrollVertical);
|
||||||
|
MouseDefaultCursorShape = CursorShape.Drag;
|
||||||
|
}
|
||||||
|
else if (_panning)
|
||||||
|
{
|
||||||
|
EndPan();
|
||||||
|
}
|
||||||
|
|
||||||
|
AcceptEvent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (@event is InputEventMouseMotion motion && _panning)
|
||||||
|
{
|
||||||
|
if (!motion.ButtonMask.HasFlag(MouseButtonMask.Left))
|
||||||
|
{
|
||||||
|
EndPan();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scroll = GetParent<ScrollContainer>();
|
||||||
|
if (scroll != null)
|
||||||
|
{
|
||||||
|
var delta = motion.GlobalPosition - _panStartMouse;
|
||||||
|
scroll.ScrollHorizontal = Mathf.RoundToInt(_panStartScroll.X - delta.X);
|
||||||
|
scroll.ScrollVertical = Mathf.RoundToInt(_panStartScroll.Y - delta.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
AcceptEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EndPan()
|
||||||
|
{
|
||||||
|
_panning = false;
|
||||||
|
MouseDefaultCursorShape = CursorShape.Arrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Theming;
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Level 1 of the collections tab: the gallery of ready-to-play games from
|
||||||
|
/// the hub mockup. One card per <see cref="ModCollectionKind.Game"/>
|
||||||
|
/// collection, built exclusively from
|
||||||
|
/// <see cref="ModCollectionGallery.Build"/> over the live
|
||||||
|
/// <see cref="ModCollectionStore.LoadAll"/> merge — there are no hardcoded
|
||||||
|
/// collection ids, so any mod, catalog or player document appears here
|
||||||
|
/// automatically. Every card shows the source badge from
|
||||||
|
/// <see cref="ModCollection.Origin"/>, status badges derived from the
|
||||||
|
/// summary (draft, missing, problems, enable, ready), the Installed/Total
|
||||||
|
/// progress bar and its action buttons; the plan is applied through
|
||||||
|
/// <see cref="ModCollectionInstaller"/> exactly like the scheme editor does.
|
||||||
|
/// <para/>
|
||||||
|
/// The whole layout is code-built (no .tscn) following the UiKit helper
|
||||||
|
/// style; a dashed "build your own game" card and the empty state with a
|
||||||
|
/// hint finish the screen.
|
||||||
|
/// </summary>
|
||||||
|
public partial class CollectionsGallery : ScrollContainer
|
||||||
|
{
|
||||||
|
/// <summary>Requested to open the scheme editor for the collection id.</summary>
|
||||||
|
public event Action<string> EditSchemeRequested;
|
||||||
|
|
||||||
|
/// <summary>Requested to create a fresh player game in the editor.</summary>
|
||||||
|
public event Action NewGameRequested;
|
||||||
|
|
||||||
|
/// <summary>Progress/status line for the hosting hub banner; null hides it.</summary>
|
||||||
|
public event Action<string> StatusMessage;
|
||||||
|
|
||||||
|
private VBoxContainer _host;
|
||||||
|
private HFlowContainer _grid;
|
||||||
|
private VBoxContainer _emptyBox;
|
||||||
|
private Label _emptyHint;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, ModCollection> _index = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, ModCollectionPlan> _plans = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly List<ModCollectionGameSummary> _summaries = new();
|
||||||
|
private HashSet<string> _sessionInstalled = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private string _query = string.Empty;
|
||||||
|
private string _installingId;
|
||||||
|
|
||||||
|
public CollectionsGallery()
|
||||||
|
{
|
||||||
|
SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
SizeFlagsVertical = SizeFlags.ExpandFill;
|
||||||
|
HorizontalScrollMode = ScrollMode.Disabled;
|
||||||
|
VerticalScrollMode = ScrollMode.Auto;
|
||||||
|
|
||||||
|
var margin = new MarginContainer();
|
||||||
|
margin.AddThemeConstantOverride("margin_left", UiTokens.Space8);
|
||||||
|
margin.AddThemeConstantOverride("margin_top", UiTokens.Space6);
|
||||||
|
margin.AddThemeConstantOverride("margin_right", UiTokens.Space8);
|
||||||
|
margin.AddThemeConstantOverride("margin_bottom", UiTokens.Space7);
|
||||||
|
margin.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
AddChild(margin);
|
||||||
|
|
||||||
|
_host = UiKit.Column(UiTokens.Space5);
|
||||||
|
_host.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
margin.AddChild(_host);
|
||||||
|
|
||||||
|
BuildCrumbs();
|
||||||
|
BuildHead();
|
||||||
|
BuildGrid();
|
||||||
|
BuildEmptyState();
|
||||||
|
|
||||||
|
ModDownloader.OnProgress += OnInstallProgress;
|
||||||
|
CatalogService.OnRefreshed += OnCatalogRefreshed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Unsubscribes the static events; the gallery is freed together with the hub.</summary>
|
||||||
|
public override void _ExitTree()
|
||||||
|
{
|
||||||
|
ModDownloader.OnProgress -= OnInstallProgress;
|
||||||
|
CatalogService.OnRefreshed -= OnCatalogRefreshed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shares the hub-wide set of mods installed during this session.</summary>
|
||||||
|
public void SetSessionInstalled(HashSet<string> sessionInstalled)
|
||||||
|
=> _sessionInstalled = sessionInstalled ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Applies the hub header search query; empty text shows everything.</summary>
|
||||||
|
public void ApplyFilter(string query)
|
||||||
|
{
|
||||||
|
_query = query ?? string.Empty;
|
||||||
|
RenderGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reloads the merged collection store, rebuilds the summaries through
|
||||||
|
/// the core gallery and the per-game apply plans, then re-renders.
|
||||||
|
/// Called on open, after a catalog refresh and after applying a plan.
|
||||||
|
/// </summary>
|
||||||
|
public void Refresh()
|
||||||
|
{
|
||||||
|
_index.Clear();
|
||||||
|
foreach (var collection in ModCollectionStore.LoadAll())
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(collection?.Id))
|
||||||
|
_index[collection.Id] = collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
var catalog = CatalogService.CachedEntries ?? new List<CatalogExportEntry>();
|
||||||
|
var summaries = ModCollectionGallery.Build(
|
||||||
|
_index,
|
||||||
|
ModRegistry.Instance.AllDiscoveredMods,
|
||||||
|
DisabledModsStore.Load(),
|
||||||
|
catalog,
|
||||||
|
_sessionInstalled,
|
||||||
|
CollectionText.Resolve);
|
||||||
|
|
||||||
|
_summaries.Clear();
|
||||||
|
_plans.Clear();
|
||||||
|
foreach (var summary in summaries)
|
||||||
|
{
|
||||||
|
_summaries.Add(summary);
|
||||||
|
_plans[summary.Collection.Id] = BuildPlan(summary, catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════ Static skeleton ═══════════════ */
|
||||||
|
|
||||||
|
private void BuildCrumbs()
|
||||||
|
{
|
||||||
|
var row = UiKit.Row(UiTokens.Space2);
|
||||||
|
row.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/hub_tab_collections"), UiTokens.FontBodySmall, UiTokens.Faint));
|
||||||
|
row.AddChild(UiKit.Label("›", UiTokens.FontBodySmall, UiTokens.Faint));
|
||||||
|
row.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/gallery_crumb"), UiTokens.FontBodySmall, UiTokens.Body));
|
||||||
|
row.AddChild(UiKit.Label("·", UiTokens.FontBodySmall, UiTokens.Faint));
|
||||||
|
row.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/gallery_hint"), UiTokens.FontBodySmall, UiTokens.Faint));
|
||||||
|
_host.AddChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildHead()
|
||||||
|
{
|
||||||
|
var row = UiKit.Row(UiTokens.Space3);
|
||||||
|
row.AddChild(UiKit.SectionTitle(TranslationServer.Translate("mod_manager/gallery_title"), UiTokens.FontLead));
|
||||||
|
row.AddChild(UiKit.Spacer());
|
||||||
|
|
||||||
|
var newGame = UiKit.Button(TranslationServer.Translate("mod_manager/gallery_new_game"), ButtonKind.Primary);
|
||||||
|
newGame.Pressed += () => NewGameRequested?.Invoke();
|
||||||
|
row.AddChild(newGame);
|
||||||
|
_host.AddChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildGrid()
|
||||||
|
{
|
||||||
|
_grid = new HFlowContainer();
|
||||||
|
_grid.AddThemeConstantOverride("h_separation", UiTokens.Space5);
|
||||||
|
_grid.AddThemeConstantOverride("v_separation", UiTokens.Space5);
|
||||||
|
_grid.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
_grid.SizeFlagsVertical = SizeFlags.ExpandFill;
|
||||||
|
_host.AddChild(_grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildEmptyState()
|
||||||
|
{
|
||||||
|
_emptyBox = UiKit.Column(UiTokens.Space2);
|
||||||
|
_emptyBox.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
_emptyBox.SizeFlagsVertical = SizeFlags.ExpandFill;
|
||||||
|
_emptyBox.Alignment = BoxContainer.AlignmentMode.Center;
|
||||||
|
_emptyBox.Visible = false;
|
||||||
|
|
||||||
|
var title = UiKit.EmptyState(TranslationServer.Translate("mod_manager/collections_empty"), UiTokens.FontLead);
|
||||||
|
_emptyHint = UiKit.Label(TranslationServer.Translate("mod_manager/gallery_empty_hint"), UiTokens.FontBodySmall, UiTokens.Faint);
|
||||||
|
_emptyHint.HorizontalAlignment = HorizontalAlignment.Center;
|
||||||
|
_emptyHint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||||
|
|
||||||
|
_emptyBox.AddChild(title);
|
||||||
|
_emptyBox.AddChild(_emptyHint);
|
||||||
|
_host.AddChild(_emptyBox);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════ Cards ═══════════════ */
|
||||||
|
|
||||||
|
private void RenderGrid()
|
||||||
|
{
|
||||||
|
ClearChildren(_grid);
|
||||||
|
|
||||||
|
var items = Filtered();
|
||||||
|
_emptyBox.Visible = items.Count == 0;
|
||||||
|
_grid.Visible = items.Count > 0;
|
||||||
|
|
||||||
|
foreach (var summary in items)
|
||||||
|
_grid.AddChild(BuildCard(summary));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ModCollectionGameSummary> Filtered()
|
||||||
|
{
|
||||||
|
var query = _query.Trim();
|
||||||
|
if (query.Length == 0)
|
||||||
|
return _summaries;
|
||||||
|
|
||||||
|
return _summaries
|
||||||
|
.Where(s => (s.Name ?? string.Empty).Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| (s.Description ?? string.Empty).Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| (s.Collection?.Id ?? string.Empty).Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| MatchesTags(s.Collection?.Tags, query))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesTags(List<string> tags, string query)
|
||||||
|
{
|
||||||
|
if (tags == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var tag in tags)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(tag) && tag.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control BuildCard(ModCollectionGameSummary summary)
|
||||||
|
{
|
||||||
|
var card = new PanelContainer { CustomMinimumSize = new Vector2(360, 0) };
|
||||||
|
card.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusLg, UiTokens.Surface2, UiTokens.Line));
|
||||||
|
|
||||||
|
var column = UiKit.Column(UiTokens.Space3);
|
||||||
|
column.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
card.AddChild(column);
|
||||||
|
|
||||||
|
var top = UiKit.Row(UiTokens.Space3);
|
||||||
|
top.AddChild(BuildCover(summary.Name ?? summary.Collection.Id));
|
||||||
|
var headColumn = UiKit.Column(2);
|
||||||
|
headColumn.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
headColumn.AddChild(UiKit.Label(summary.Name ?? summary.Collection.Id, UiTokens.FontBodyLarge, UiTokens.InkBright));
|
||||||
|
headColumn.AddChild(UiKit.Label(BuildSubtitle(summary), UiTokens.FontTiny, UiTokens.Faint));
|
||||||
|
top.AddChild(headColumn);
|
||||||
|
column.AddChild(top);
|
||||||
|
|
||||||
|
var description = UiKit.Label(
|
||||||
|
string.IsNullOrEmpty(summary.Description) ? "—" : summary.Description,
|
||||||
|
UiTokens.FontBodySmall, UiTokens.Muted);
|
||||||
|
description.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||||
|
column.AddChild(description);
|
||||||
|
|
||||||
|
var badges = UiKit.Row(UiTokens.Space1);
|
||||||
|
foreach (var (text, color) in BuildStatusBadges(summary))
|
||||||
|
badges.AddChild(UiKit.Badge(text, color));
|
||||||
|
column.AddChild(badges);
|
||||||
|
|
||||||
|
if (summary.TotalMods > 0)
|
||||||
|
{
|
||||||
|
var progress = new ProgressBar
|
||||||
|
{
|
||||||
|
MaxValue = 100,
|
||||||
|
Value = 100f * summary.InstalledCount / summary.TotalMods,
|
||||||
|
ShowPercentage = false,
|
||||||
|
CustomMinimumSize = new Vector2(0, 6),
|
||||||
|
};
|
||||||
|
progress.AddThemeStyleboxOverride("background", UiKit.Frame(UiTokens.RadiusXs, UiTokens.Surface1, UiTokens.LineSoft));
|
||||||
|
progress.AddThemeStyleboxOverride("fill", UiKit.Frame(UiTokens.RadiusXs, UiTokens.Accent, null));
|
||||||
|
column.AddChild(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
var foot = UiKit.Row(UiTokens.Space2);
|
||||||
|
foot.AddChild(BuildPrimaryButton(summary));
|
||||||
|
foot.AddChild(BuildEditButton(summary));
|
||||||
|
if (CollectionDeletion.CanDelete(summary.Collection))
|
||||||
|
foot.AddChild(BuildDeleteButton(summary));
|
||||||
|
foot.AddChild(UiKit.Spacer());
|
||||||
|
foot.AddChild(UiKit.Label(
|
||||||
|
string.Format(TranslationServer.Translate("mod_manager/collection_result"), summary.InstalledCount, summary.TotalMods),
|
||||||
|
UiTokens.FontSmall, UiTokens.Faint));
|
||||||
|
column.AddChild(foot);
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cover box with the first grapheme of the game name (mockup's emoji slot).</summary>
|
||||||
|
private static Control BuildCover(string title)
|
||||||
|
{
|
||||||
|
var panel = new PanelContainer { CustomMinimumSize = new Vector2(64, 64) };
|
||||||
|
panel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface1, UiTokens.Line));
|
||||||
|
|
||||||
|
var letter = "·";
|
||||||
|
var text = title?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
// First grapheme cluster so surrogate-pair emoji covers survive.
|
||||||
|
var info = new StringInfo(text);
|
||||||
|
if (info.LengthInTextElements > 0)
|
||||||
|
letter = info.SubstringByTextElements(0, 1).ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = UiKit.Label(letter, UiTokens.FontH3, UiTokens.AccentBright);
|
||||||
|
label.HorizontalAlignment = HorizontalAlignment.Center;
|
||||||
|
label.VerticalAlignment = VerticalAlignment.Center;
|
||||||
|
panel.AddChild(label);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildSubtitle(ModCollectionGameSummary summary)
|
||||||
|
{
|
||||||
|
var collection = summary.Collection;
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (!string.IsNullOrEmpty(collection.Author))
|
||||||
|
parts.Add(collection.Author);
|
||||||
|
if (!string.IsNullOrEmpty(collection.Version))
|
||||||
|
parts.Add("v" + collection.Version);
|
||||||
|
parts.Add(TranslationServer.Translate(OriginKey(collection.Origin)));
|
||||||
|
parts.Add($"{summary.TotalMods} {TranslationServer.Translate("mod_manager/collection_mods")}");
|
||||||
|
return string.Join(" · ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Source badge colours per <see cref="ModCollection.Origin"/> (blue built-in/catalog, violet player).</summary>
|
||||||
|
private static string OriginKey(ModCollectionOrigin origin) => origin switch
|
||||||
|
{
|
||||||
|
ModCollectionOrigin.User => "mod_manager/collection_origin_user",
|
||||||
|
ModCollectionOrigin.Catalog => "mod_manager/collection_origin_catalog",
|
||||||
|
_ => "mod_manager/collection_origin_builtin",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Status badges exactly per the hub rules: draft for empty games;
|
||||||
|
/// red/yellow "missing N" plus "problems: K" for unplayable ones;
|
||||||
|
/// amber "enable N" when only disabled mods hold the game back; green
|
||||||
|
/// "ready to play" otherwise. A disabled mod alone never breaks
|
||||||
|
/// <see cref="ModCollectionGameSummary.IsReady"/>, so the badges follow
|
||||||
|
/// the summary, not raw node states.
|
||||||
|
/// </summary>
|
||||||
|
private static List<(string Text, Color Color)> BuildStatusBadges(ModCollectionGameSummary summary)
|
||||||
|
{
|
||||||
|
var badges = new List<(string, Color)>();
|
||||||
|
Func<string, string> translate = key => TranslationServer.Translate(key);
|
||||||
|
|
||||||
|
if (summary.TotalMods == 0)
|
||||||
|
{
|
||||||
|
badges.Add((translate("mod_manager/gallery_draft"), UiTokens.Warning));
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary.IsReady)
|
||||||
|
{
|
||||||
|
var gaps = summary.MissingIds.Count + summary.MismatchIds.Count;
|
||||||
|
if (gaps > 0)
|
||||||
|
{
|
||||||
|
var color = summary.MissingIds.Count > 0 ? UiTokens.DangerText : UiTokens.Warning;
|
||||||
|
badges.Add((string.Format(translate("mod_manager/gallery_missing_n"), gaps), color));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.ProblemsCount > 0)
|
||||||
|
badges.Add((string.Format(translate("mod_manager/gallery_problems_n"), summary.ProblemsCount), UiTokens.DangerText));
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.PlanInstallCount == 0 && summary.PlanEnableCount > 0)
|
||||||
|
badges.Add((string.Format(translate("mod_manager/gallery_enable_badge_n"), summary.PlanEnableCount), UiTokens.Warning));
|
||||||
|
else
|
||||||
|
badges.Add((translate("mod_manager/gallery_ready"), UiTokens.SuccessText));
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The card's primary action: install the plan, enable the disabled mods, continue a draft or rest on a ready game.</summary>
|
||||||
|
private Button BuildPrimaryButton(ModCollectionGameSummary summary)
|
||||||
|
{
|
||||||
|
Func<string, string> translate = key => TranslationServer.Translate(key);
|
||||||
|
Button button;
|
||||||
|
Action action;
|
||||||
|
|
||||||
|
if (summary.PlanInstallCount > 0)
|
||||||
|
{
|
||||||
|
button = UiKit.Button(string.Format(translate("mod_manager/gallery_install_btn_n"), summary.PlanInstallCount), ButtonKind.Primary);
|
||||||
|
action = () => ApplyPlan(summary.Collection.Id);
|
||||||
|
button.Disabled = summary.MissingIds.Count > 0 || _installingId != null;
|
||||||
|
}
|
||||||
|
else if (summary.PlanEnableCount > 0)
|
||||||
|
{
|
||||||
|
button = UiKit.Button(string.Format(translate("mod_manager/gallery_enable_btn_n"), summary.PlanEnableCount), ButtonKind.Primary);
|
||||||
|
action = () => ApplyPlan(summary.Collection.Id);
|
||||||
|
button.Disabled = _installingId != null;
|
||||||
|
}
|
||||||
|
else if (summary.TotalMods == 0)
|
||||||
|
{
|
||||||
|
button = UiKit.Button(translate("mod_manager/gallery_continue"));
|
||||||
|
action = () => EditSchemeRequested?.Invoke(summary.Collection.Id);
|
||||||
|
button.Disabled = _installingId != null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
button = UiKit.Button(translate("mod_manager/gallery_ready_done"));
|
||||||
|
action = null;
|
||||||
|
button.Disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action != null)
|
||||||
|
button.Pressed += action;
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button BuildEditButton(ModCollectionGameSummary summary)
|
||||||
|
{
|
||||||
|
var button = UiKit.Button(TranslationServer.Translate("mod_manager/gallery_edit_scheme"));
|
||||||
|
button.Disabled = _installingId != null;
|
||||||
|
button.Pressed += () => EditSchemeRequested?.Invoke(summary.Collection.Id);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button BuildDeleteButton(ModCollectionGameSummary summary)
|
||||||
|
{
|
||||||
|
var button = UiKit.Button(TranslationServer.Translate("mod_manager/collection_delete"), ButtonKind.Danger);
|
||||||
|
button.Disabled = _installingId != null;
|
||||||
|
button.Pressed += () => CollectionDeletion.Confirm(this, summary.Collection, () =>
|
||||||
|
{
|
||||||
|
StatusMessage?.Invoke(TranslationServer.Translate("mod_manager/collection_delete_done"));
|
||||||
|
Refresh();
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════ Apply ═══════════════ */
|
||||||
|
|
||||||
|
private ModCollectionPlan BuildPlan(ModCollectionGameSummary summary, List<CatalogExportEntry> catalog)
|
||||||
|
{
|
||||||
|
var graph = ModCollectionResolver.Resolve(
|
||||||
|
summary.Collection,
|
||||||
|
_index,
|
||||||
|
ModRegistry.Instance.AllDiscoveredMods,
|
||||||
|
DisabledModsStore.Load(),
|
||||||
|
catalog,
|
||||||
|
_sessionInstalled,
|
||||||
|
null,
|
||||||
|
CollectionText.Resolve);
|
||||||
|
return ModCollectionPlan.Build(graph, catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the plan of one card through the shared runner — the same
|
||||||
|
/// install-then-enable routine the scheme editor performs — and
|
||||||
|
/// refreshes the gallery so badges and buttons reflect the new state.
|
||||||
|
/// </summary>
|
||||||
|
private async void ApplyPlan(string collectionId)
|
||||||
|
{
|
||||||
|
if (_installingId != null
|
||||||
|
|| !_plans.TryGetValue(collectionId, out var plan)
|
||||||
|
|| plan.IsEmpty
|
||||||
|
|| plan.Missing.Count > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = _summaries.FirstOrDefault(s => string.Equals(s.Collection?.Id, collectionId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
var title = summary?.Name ?? collectionId;
|
||||||
|
|
||||||
|
_installingId = collectionId;
|
||||||
|
StatusMessage?.Invoke(string.Format(TranslationServer.Translate("mod_manager/collection_progress"), title));
|
||||||
|
RenderGrid();
|
||||||
|
|
||||||
|
bool success;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
success = await ModCollectionInstaller.ApplyAsync(plan, _sessionInstalled);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MOD_HUB", $"Gallery apply '{collectionId}' failed: {ex.Message}", LogLevel.Error);
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_installingId = null;
|
||||||
|
|
||||||
|
if (!GodotObject.IsInstanceValid(this))
|
||||||
|
return;
|
||||||
|
|
||||||
|
StatusMessage?.Invoke(TranslationServer.Translate(success
|
||||||
|
? "mod_manager/restart_required"
|
||||||
|
: "mod_manager/collection_apply_failed"));
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInstallProgress(InstallProgress progress)
|
||||||
|
{
|
||||||
|
if (_installingId == null || progress == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
StatusMessage?.Invoke(progress.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCatalogRefreshed()
|
||||||
|
{
|
||||||
|
if (GodotObject.IsInstanceValid(this))
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ClearChildren(Node node)
|
||||||
|
{
|
||||||
|
foreach (var child in node.GetChildren())
|
||||||
|
{
|
||||||
|
node.RemoveChild(child);
|
||||||
|
child.QueueFree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Nexus-style mod tile. The layout lives in
|
||||||
|
/// <c>scenes/components/mod_card.tscn</c> so it can be edited in the Godot
|
||||||
|
/// editor; this script only binds data and reacts to input. It exposes a
|
||||||
|
/// cover image, title (always two lines tall), author, catalog source, status
|
||||||
|
/// badge, version combo box, a single contextual action button and an
|
||||||
|
/// enable/disable switch.
|
||||||
|
/// <para/>
|
||||||
|
/// All rows are reserved to the same height on purpose: tiles in the grid must
|
||||||
|
/// look identical regardless of whether a mod has a catalog entry, versions,
|
||||||
|
/// an author or an action. Version selection mutates the shared
|
||||||
|
/// <see cref="ModCardData.SelectedDownload"/> and refreshes the tile in place,
|
||||||
|
/// so changing versions never rebuilds the grid or loses scroll position.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ModCard : PanelContainer
|
||||||
|
{
|
||||||
|
private ModCardData _data;
|
||||||
|
private TextureRect _image;
|
||||||
|
private Label _title;
|
||||||
|
private Label _author;
|
||||||
|
private Label _source;
|
||||||
|
private Label _status;
|
||||||
|
private OptionButton _versions;
|
||||||
|
private Button _action;
|
||||||
|
private CheckButton _enabled;
|
||||||
|
private HBoxContainer _metaRow;
|
||||||
|
private Button _collectionsLink;
|
||||||
|
private bool _updatingEnabled;
|
||||||
|
private bool _initialized;
|
||||||
|
|
||||||
|
/// <summary>Raised when the card body (not a control) is clicked.</summary>
|
||||||
|
public event Action<ModCardData> Clicked;
|
||||||
|
|
||||||
|
/// <summary>Raised when the "in N collections" cross-link badge is pressed.</summary>
|
||||||
|
public event Action CollectionsLinkRequested;
|
||||||
|
|
||||||
|
/// <summary>Raised when the contextual action button is pressed.</summary>
|
||||||
|
public event Action<ModCardData> ActionPressed;
|
||||||
|
|
||||||
|
/// <summary>Raised when the enable switch is flipped; the status is already updated.</summary>
|
||||||
|
public event Action<ModCardData> EnabledToggled;
|
||||||
|
|
||||||
|
/// <summary>Raised after a different catalog version was selected.</summary>
|
||||||
|
public event Action<ModCardData> VersionChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds the tile to its data. Called once by <see cref="ModGridView"/>
|
||||||
|
/// right after the scene is instantiated; the card cannot be used before.
|
||||||
|
/// </summary>
|
||||||
|
public void Init(ModCardData data)
|
||||||
|
{
|
||||||
|
if (_initialized)
|
||||||
|
return;
|
||||||
|
_initialized = true;
|
||||||
|
_data = data;
|
||||||
|
|
||||||
|
_image = GetNode<TextureRect>("VBox/Cover/Image");
|
||||||
|
_title = GetNode<Label>("VBox/Body/Box/Title");
|
||||||
|
_author = GetNode<Label>("VBox/Body/Box/Author");
|
||||||
|
_source = GetNode<Label>("VBox/Body/Box/Source");
|
||||||
|
_status = GetNode<Label>("VBox/Body/Box/MetaRow/Status");
|
||||||
|
_versions = GetNode<OptionButton>("VBox/Body/Box/MetaRow/VersionHost/Versions");
|
||||||
|
_action = GetNode<Button>("VBox/Body/Box/ActionRow/ActionHost/Action");
|
||||||
|
_enabled = GetNode<CheckButton>("VBox/Body/Box/ActionRow/EnabledHost/Enabled");
|
||||||
|
_metaRow = GetNode<HBoxContainer>("VBox/Body/Box/MetaRow");
|
||||||
|
|
||||||
|
_author.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
_source.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
AddThemeStyleboxOverride("panel", ModManagerTheme.CreateCardStyle(data.IsConflict));
|
||||||
|
|
||||||
|
_versions.ItemSelected += OnVersionSelected;
|
||||||
|
_action.Pressed += OnActionPressed;
|
||||||
|
_enabled.Toggled += OnEnabledPressed;
|
||||||
|
GuiInput += OnGuiInput;
|
||||||
|
|
||||||
|
BuildCollectionsLink();
|
||||||
|
PopulateVersions();
|
||||||
|
RefreshState();
|
||||||
|
LoadPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resynchronizes badge, version list, action and switch with the data.</summary>
|
||||||
|
public void RefreshState()
|
||||||
|
{
|
||||||
|
if (!_initialized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_title.Text = _data.Title ?? _data.Id;
|
||||||
|
_author.Text = _data.Author ?? string.Empty;
|
||||||
|
UpdateSourceLabel();
|
||||||
|
UpdateCollectionsLink();
|
||||||
|
|
||||||
|
if (_data.IsConflict)
|
||||||
|
{
|
||||||
|
_status.Text = TranslationServer.Translate("mod_manager/status_conflict");
|
||||||
|
_status.AddThemeColorOverride("font_color", UiTokens.Danger);
|
||||||
|
_status.TooltipText = TranslationServer.Translate("mod_manager/conflict_warning");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_status.Text = ModStatusNames.Translate(_data.Status);
|
||||||
|
_status.AddThemeColorOverride("font_color", ModStatusNames.Color(_data.Status));
|
||||||
|
_status.TooltipText = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateActionButton();
|
||||||
|
UpdateEnabledToggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSourceLabel()
|
||||||
|
{
|
||||||
|
if (_data.Catalog == null)
|
||||||
|
{
|
||||||
|
_source.Text = TranslationServer.Translate("mod_manager/source_local");
|
||||||
|
_source.TooltipText = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = ModCatalogSource.Label(_data.SourceUrls) ?? "—";
|
||||||
|
_source.Text = string.Format(TranslationServer.Translate("mod_manager/source_catalog"), label);
|
||||||
|
_source.TooltipText = ModCatalogSource.Tooltip(_data.SourceUrls) ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateVersions()
|
||||||
|
{
|
||||||
|
_versions.Clear();
|
||||||
|
|
||||||
|
if (_data.Downloads.Count == 0)
|
||||||
|
{
|
||||||
|
_versions.AddItem("v" + (_data.LocalVersion ?? "—"));
|
||||||
|
_versions.Disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_versions.Disabled = false;
|
||||||
|
var selectedIndex = 0;
|
||||||
|
for (var i = 0; i < _data.Downloads.Count; i++)
|
||||||
|
{
|
||||||
|
var download = _data.Downloads[i];
|
||||||
|
var label = "v" + (download.Version ?? "?");
|
||||||
|
if (i == 0)
|
||||||
|
label += " · " + TranslationServer.Translate("mod_manager/latest");
|
||||||
|
if (_data.IsInstalled
|
||||||
|
&& !string.IsNullOrEmpty(download.Version)
|
||||||
|
&& string.Equals(download.Version, _data.LocalVersion, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
label += " · " + TranslationServer.Translate("mod_manager/installed_marker");
|
||||||
|
}
|
||||||
|
|
||||||
|
_versions.AddItem(label);
|
||||||
|
|
||||||
|
if (ReferenceEquals(download, _data.SelectedDownload))
|
||||||
|
selectedIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
_versions.Selected = selectedIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the flat "in N collections" cross-link shown between the
|
||||||
|
/// status label and the version combo. The link is part of the hub
|
||||||
|
/// mockup: pressing it jumps to the collections tab. Hidden entirely
|
||||||
|
/// for mods that no playable game references.
|
||||||
|
/// </summary>
|
||||||
|
private void BuildCollectionsLink()
|
||||||
|
{
|
||||||
|
_collectionsLink = new Button
|
||||||
|
{
|
||||||
|
FocusMode = FocusModeEnum.None,
|
||||||
|
Visible = false,
|
||||||
|
};
|
||||||
|
_collectionsLink.AddThemeFontSizeOverride("font_size", UiTokens.FontTiny);
|
||||||
|
_collectionsLink.AddThemeColorOverride("font_color", UiTokens.TypeTarget);
|
||||||
|
_collectionsLink.AddThemeColorOverride("font_hover_color", UiTokens.TypeTarget);
|
||||||
|
_collectionsLink.AddThemeColorOverride("font_pressed_color", UiTokens.TypeTarget);
|
||||||
|
_collectionsLink.AddThemeColorOverride("font_focus_color", UiTokens.TypeTarget);
|
||||||
|
foreach (var state in new[] { "normal", "hover", "pressed", "disabled", "focus" })
|
||||||
|
_collectionsLink.AddThemeStyleboxOverride(state, new StyleBoxEmpty());
|
||||||
|
_collectionsLink.Pressed += () => CollectionsLinkRequested?.Invoke();
|
||||||
|
_metaRow.AddChild(_collectionsLink);
|
||||||
|
_metaRow.MoveChild(_collectionsLink, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCollectionsLink()
|
||||||
|
{
|
||||||
|
var text = ModCollectionLinks.BadgeText(_data.CollectionsCount);
|
||||||
|
_collectionsLink.Visible = text.Length > 0;
|
||||||
|
_collectionsLink.Text = text;
|
||||||
|
_collectionsLink.TooltipText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateActionButton()
|
||||||
|
{
|
||||||
|
var kind = ModActions.GetPrimary(_data);
|
||||||
|
if (kind == null)
|
||||||
|
{
|
||||||
|
_action.Visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_action.Visible = true;
|
||||||
|
_action.Text = ModActionNames.Translate(kind.Value);
|
||||||
|
|
||||||
|
if (ModActionNames.IsDanger(kind.Value))
|
||||||
|
CthangoverTheme.ApplyDanger(_action);
|
||||||
|
else
|
||||||
|
CthangoverTheme.ApplyPrimary(_action);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateEnabledToggle()
|
||||||
|
{
|
||||||
|
_enabled.Visible = _data.CanToggleEnabled;
|
||||||
|
if (!_data.CanToggleEnabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_updatingEnabled = true;
|
||||||
|
_enabled.ButtonPressed = !_data.IsDisabled;
|
||||||
|
_updatingEnabled = false;
|
||||||
|
_enabled.TooltipText = TranslationServer.Translate(_data.IsDisabled
|
||||||
|
? "mod_manager/action_enable"
|
||||||
|
: "mod_manager/action_disable");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadPreview()
|
||||||
|
{
|
||||||
|
if (_data.LocalInfo != null)
|
||||||
|
{
|
||||||
|
var local = ModPreviewLoader.LoadLocal(_data.LocalInfo);
|
||||||
|
if (local != null)
|
||||||
|
{
|
||||||
|
_image.Texture = local;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_data.Catalog != null)
|
||||||
|
ModPreviewLoader.LoadRemote(_data.Id, _data.Catalog.IconUrl, OnRemotePreviewLoaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRemotePreviewLoaded(Texture2D texture)
|
||||||
|
{
|
||||||
|
if (texture != null && GodotObject.IsInstanceValid(_image))
|
||||||
|
_image.Texture = texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGuiInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event is InputEventMouseButton mouseButton
|
||||||
|
&& mouseButton.Pressed
|
||||||
|
&& mouseButton.ButtonIndex == MouseButton.Left)
|
||||||
|
{
|
||||||
|
Clicked?.Invoke(_data);
|
||||||
|
AcceptEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnActionPressed()
|
||||||
|
{
|
||||||
|
ActionPressed?.Invoke(_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnabledPressed(bool pressed)
|
||||||
|
{
|
||||||
|
if (_updatingEnabled || !_data.CanToggleEnabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_data.IsDisabled = !pressed;
|
||||||
|
_data.RecomputeStatus();
|
||||||
|
RefreshState();
|
||||||
|
EnabledToggled?.Invoke(_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnVersionSelected(long index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= _data.Downloads.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_data.SelectedDownload = _data.Downloads[(int)index];
|
||||||
|
_data.RecomputeStatus();
|
||||||
|
RefreshState();
|
||||||
|
VersionChanged?.Invoke(_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Full-screen page dedicated to one mod (opened by clicking a tile). The
|
||||||
|
/// layout lives in <c>scenes/components/mod_detail_view.tscn</c> so it can be
|
||||||
|
/// edited visually; this script fills it with data, rebuilds the contextual
|
||||||
|
/// action row and reports user intent through events — the manager screen
|
||||||
|
/// performs the actual file operations.
|
||||||
|
/// <para/>
|
||||||
|
/// Conflicting catalog variants (same version, different content) get a red
|
||||||
|
/// warning block explaining both sources, on top of the red tile border the
|
||||||
|
/// card already shows.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ModDetailView : VBoxContainer
|
||||||
|
{
|
||||||
|
private ModCardData _data;
|
||||||
|
private bool _nodesReady;
|
||||||
|
|
||||||
|
private TextureRect _coverImage;
|
||||||
|
private Button _back;
|
||||||
|
private Label _title;
|
||||||
|
private Label _author;
|
||||||
|
private Label _meta;
|
||||||
|
private OptionButton _versions;
|
||||||
|
private Label _installed;
|
||||||
|
private Label _size;
|
||||||
|
private HBoxContainer _actionsRow;
|
||||||
|
private VBoxContainer _conflictBox;
|
||||||
|
private Label _conflictText;
|
||||||
|
private Label _descriptionText;
|
||||||
|
private Label _changelogText;
|
||||||
|
private VBoxContainer _dependsBox;
|
||||||
|
private Label _dependsText;
|
||||||
|
private VBoxContainer _usedByBox;
|
||||||
|
private Label _usedByText;
|
||||||
|
private VBoxContainer _conflictsBox;
|
||||||
|
private Label _conflictsText;
|
||||||
|
|
||||||
|
/// <summary>Raised by the back button.</summary>
|
||||||
|
public event Action BackRequested;
|
||||||
|
|
||||||
|
/// <summary>Raised when the primary contextual action is pressed.</summary>
|
||||||
|
public event Action<ModCardData> ActionPressed;
|
||||||
|
|
||||||
|
/// <summary>Raised when the standalone delete button is pressed.</summary>
|
||||||
|
public event Action<ModCardData> DeleteRequested;
|
||||||
|
|
||||||
|
/// <summary>Raised when the enable/disable button is pressed.</summary>
|
||||||
|
public event Action<ModCardData> EnabledToggled;
|
||||||
|
|
||||||
|
/// <summary>Raised after a different catalog version is selected.</summary>
|
||||||
|
public event Action<ModCardData> VersionChanged;
|
||||||
|
|
||||||
|
/// <summary>Raised when the "in N collections" cross-link is pressed.</summary>
|
||||||
|
public event Action CollectionsLinkRequested;
|
||||||
|
|
||||||
|
/// <summary>Displays the given mod; call again with refreshed data after actions.</summary>
|
||||||
|
public void Open(ModCardData data)
|
||||||
|
{
|
||||||
|
_data = data;
|
||||||
|
Rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureNodes()
|
||||||
|
{
|
||||||
|
if (_nodesReady)
|
||||||
|
return;
|
||||||
|
_nodesReady = true;
|
||||||
|
|
||||||
|
_coverImage = GetNode<TextureRect>("Scroll/Content/Hero/HeroMargin/HeroRow/Cover/Image");
|
||||||
|
_back = GetNode<Button>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Back");
|
||||||
|
_title = GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Title");
|
||||||
|
_author = GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Author");
|
||||||
|
_meta = GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Meta");
|
||||||
|
_versions = GetNode<OptionButton>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Versions");
|
||||||
|
_installed = GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Installed");
|
||||||
|
_size = GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Size");
|
||||||
|
_actionsRow = GetNode<HBoxContainer>("Scroll/Content/ActionsRow");
|
||||||
|
_conflictBox = GetNode<VBoxContainer>("Scroll/Content/ConflictBox");
|
||||||
|
_conflictText = GetNode<Label>("Scroll/Content/ConflictBox/Text");
|
||||||
|
_descriptionText = GetNode<Label>("Scroll/Content/DescriptionBox/Text");
|
||||||
|
_changelogText = GetNode<Label>("Scroll/Content/ChangelogBox/Text");
|
||||||
|
_dependsBox = GetNode<VBoxContainer>("Scroll/Content/DependsBox");
|
||||||
|
_dependsText = GetNode<Label>("Scroll/Content/DependsBox/Text");
|
||||||
|
_usedByBox = GetNode<VBoxContainer>("Scroll/Content/UsedByBox");
|
||||||
|
_usedByText = GetNode<Label>("Scroll/Content/UsedByBox/Text");
|
||||||
|
_conflictsBox = GetNode<VBoxContainer>("Scroll/Content/ConflictsBox");
|
||||||
|
_conflictsText = GetNode<Label>("Scroll/Content/ConflictsBox/Text");
|
||||||
|
|
||||||
|
_back.Text = TranslationServer.Translate("mod_manager/back_to_list");
|
||||||
|
_back.Pressed += OnBackPressed;
|
||||||
|
_versions.ItemSelected += OnVersionSelected;
|
||||||
|
|
||||||
|
_author.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
_installed.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
_size.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
_conflictText.AddThemeColorOverride("font_color", UiTokens.Danger);
|
||||||
|
|
||||||
|
SetCaption("Scroll/Content/ConflictBox/Caption", "mod_manager/conflict_title");
|
||||||
|
SetCaption("Scroll/Content/DescriptionBox/Caption", "mod_manager/description");
|
||||||
|
SetCaption("Scroll/Content/ChangelogBox/Caption", "mod_manager/changelog");
|
||||||
|
SetCaption("Scroll/Content/DependsBox/Caption", "mod_manager/depends");
|
||||||
|
SetCaption("Scroll/Content/UsedByBox/Caption", "mod_manager/used_by");
|
||||||
|
SetCaption("Scroll/Content/ConflictsBox/Caption", "mod_manager/conflicts");
|
||||||
|
SetCaption("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Caption", "mod_manager/version");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetCaption(string path, string localeKey)
|
||||||
|
{
|
||||||
|
var caption = GetNode<Label>(path);
|
||||||
|
caption.Text = TranslationServer.Translate(localeKey);
|
||||||
|
caption.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Rebuild()
|
||||||
|
{
|
||||||
|
EnsureNodes();
|
||||||
|
if (_data == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_title.Text = _data.Title ?? _data.Id;
|
||||||
|
_author.Text = _data.Author ?? string.Empty;
|
||||||
|
|
||||||
|
if (_data.IsConflict)
|
||||||
|
{
|
||||||
|
_meta.Text = TranslationServer.Translate("mod_manager/status_conflict");
|
||||||
|
_meta.AddThemeColorOverride("font_color", UiTokens.Danger);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_meta.Text = $"{ModCategoryNames.Translate(ModCategoryNames.Primary(_data.Category))} · {ModStatusNames.Translate(_data.Status)}";
|
||||||
|
_meta.AddThemeColorOverride("font_color", ModStatusNames.Color(_data.Status));
|
||||||
|
}
|
||||||
|
|
||||||
|
PopulateVersions();
|
||||||
|
_installed.Text = _data.IsInstalled
|
||||||
|
? string.Format(TranslationServer.Translate("mod_manager/installed_version"), _data.LocalVersion ?? "—")
|
||||||
|
: TranslationServer.Translate("mod_manager/not_installed_hint").ToString();
|
||||||
|
|
||||||
|
var size = _data.SelectedDownload?.SizeBytes ?? 0;
|
||||||
|
_size.Text = size > 0 ? FormatSize(size) : string.Empty;
|
||||||
|
|
||||||
|
BuildActions();
|
||||||
|
BuildConflictBox();
|
||||||
|
|
||||||
|
_descriptionText.Text = NullIfEmpty(_data.Description) ?? "—";
|
||||||
|
_changelogText.Text = NullIfEmpty(_data.SelectedDownload?.Changelog) ?? "—";
|
||||||
|
SetOptional(_dependsBox, _dependsText, JoinOrEmpty(ModQueries.GetDependencies(_data)));
|
||||||
|
SetOptional(_usedByBox, _usedByText, JoinOrEmpty(ModQueries.GetDependents(_data.Id)));
|
||||||
|
SetOptional(_conflictsBox, _conflictsText, JoinOrEmpty(ModQueries.GetConflicts(_data)));
|
||||||
|
|
||||||
|
LoadCover();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateVersions()
|
||||||
|
{
|
||||||
|
_versions.Clear();
|
||||||
|
_versions.Visible = _data.Downloads.Count > 0;
|
||||||
|
if (_data.Downloads.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var selectedIndex = 0;
|
||||||
|
for (var i = 0; i < _data.Downloads.Count; i++)
|
||||||
|
{
|
||||||
|
var download = _data.Downloads[i];
|
||||||
|
var label = "v" + (download.Version ?? "?");
|
||||||
|
if (i == 0)
|
||||||
|
label += " · " + TranslationServer.Translate("mod_manager/latest");
|
||||||
|
if (_data.IsInstalled
|
||||||
|
&& !string.IsNullOrEmpty(download.Version)
|
||||||
|
&& string.Equals(download.Version, _data.LocalVersion, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
label += " · " + TranslationServer.Translate("mod_manager/installed_marker");
|
||||||
|
}
|
||||||
|
|
||||||
|
_versions.AddItem(label);
|
||||||
|
|
||||||
|
if (ReferenceEquals(download, _data.SelectedDownload))
|
||||||
|
selectedIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
_versions.Selected = selectedIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildActions()
|
||||||
|
{
|
||||||
|
foreach (var child in _actionsRow.GetChildren())
|
||||||
|
{
|
||||||
|
_actionsRow.RemoveChild(child);
|
||||||
|
child.QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
var primary = ModActions.GetPrimary(_data);
|
||||||
|
if (primary != null)
|
||||||
|
{
|
||||||
|
var button = new Button { Text = ModActionNames.Translate(primary.Value) };
|
||||||
|
if (ModActionNames.IsDanger(primary.Value))
|
||||||
|
CthangoverTheme.ApplyDanger(button);
|
||||||
|
else
|
||||||
|
CthangoverTheme.ApplyPrimary(button);
|
||||||
|
button.Pressed += OnPrimaryPressed;
|
||||||
|
_actionsRow.AddChild(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primary != ModActionKind.Delete && _data.IsDeletable)
|
||||||
|
{
|
||||||
|
var delete = new Button { Text = ModActionNames.Translate(ModActionKind.Delete) };
|
||||||
|
CthangoverTheme.ApplyDanger(delete);
|
||||||
|
delete.Pressed += OnDeletePressed;
|
||||||
|
_actionsRow.AddChild(delete);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_data.CanToggleEnabled)
|
||||||
|
{
|
||||||
|
var toggle = new Button
|
||||||
|
{
|
||||||
|
Text = ModActionNames.Translate(_data.IsDisabled
|
||||||
|
? ModActionKind.Enable
|
||||||
|
: ModActionKind.Disable),
|
||||||
|
};
|
||||||
|
if (_data.IsDisabled)
|
||||||
|
CthangoverTheme.ApplyPrimary(toggle);
|
||||||
|
else
|
||||||
|
CthangoverTheme.ApplyDanger(toggle);
|
||||||
|
toggle.Pressed += OnTogglePressed;
|
||||||
|
_actionsRow.AddChild(toggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_data.IsProtected)
|
||||||
|
{
|
||||||
|
var hint = new Label
|
||||||
|
{
|
||||||
|
Text = TranslationServer.Translate("mod_manager/self_hint"),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
hint.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||||
|
hint.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
_actionsRow.AddChild(hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
var collectionsText = ModCollectionLinks.BadgeText(_data.CollectionsCount);
|
||||||
|
if (collectionsText.Length > 0)
|
||||||
|
{
|
||||||
|
var link = new Button
|
||||||
|
{
|
||||||
|
Text = collectionsText,
|
||||||
|
TooltipText = collectionsText,
|
||||||
|
FocusMode = FocusModeEnum.None,
|
||||||
|
};
|
||||||
|
link.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||||
|
link.AddThemeColorOverride("font_color", UiTokens.TypeTarget);
|
||||||
|
link.AddThemeColorOverride("font_hover_color", UiTokens.TypeTarget);
|
||||||
|
link.Pressed += () => CollectionsLinkRequested?.Invoke();
|
||||||
|
_actionsRow.AddChild(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildConflictBox()
|
||||||
|
{
|
||||||
|
_conflictBox.Visible = _data.IsConflict;
|
||||||
|
if (!_data.IsConflict)
|
||||||
|
{
|
||||||
|
_conflictText.Text = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sources = ModCatalogSource.Tooltip(_data.SourceUrls);
|
||||||
|
_conflictText.Text = sources == null
|
||||||
|
? TranslationServer.Translate("mod_manager/conflict_warning")
|
||||||
|
: $"{TranslationServer.Translate("mod_manager/conflict_warning")}\n{sources}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadCover()
|
||||||
|
{
|
||||||
|
_coverImage.Texture = null;
|
||||||
|
|
||||||
|
if (_data.LocalInfo != null)
|
||||||
|
{
|
||||||
|
var local = ModPreviewLoader.LoadLocal(_data.LocalInfo);
|
||||||
|
if (local != null)
|
||||||
|
{
|
||||||
|
_coverImage.Texture = local;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_data.Catalog != null)
|
||||||
|
ModPreviewLoader.LoadRemote(_data.Id, _data.Catalog.IconUrl, OnRemotePreviewLoaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRemotePreviewLoaded(Texture2D texture)
|
||||||
|
{
|
||||||
|
if (texture != null && GodotObject.IsInstanceValid(_coverImage))
|
||||||
|
_coverImage.Texture = texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBackPressed() => BackRequested?.Invoke();
|
||||||
|
|
||||||
|
private void OnPrimaryPressed() => ActionPressed?.Invoke(_data);
|
||||||
|
|
||||||
|
private void OnDeletePressed() => DeleteRequested?.Invoke(_data);
|
||||||
|
|
||||||
|
private void OnTogglePressed() => EnabledToggled?.Invoke(_data);
|
||||||
|
|
||||||
|
private void OnVersionSelected(long index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= _data.Downloads.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_data.SelectedDownload = _data.Downloads[(int)index];
|
||||||
|
_data.RecomputeStatus();
|
||||||
|
Rebuild();
|
||||||
|
VersionChanged?.Invoke(_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetOptional(VBoxContainer box, Label text, string value)
|
||||||
|
{
|
||||||
|
box.Visible = !string.IsNullOrEmpty(value);
|
||||||
|
text.Text = value ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NullIfEmpty(string value)
|
||||||
|
=> string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
|
||||||
|
private static string JoinOrEmpty(System.Collections.Generic.List<string> values)
|
||||||
|
=> values == null || values.Count == 0 ? null : string.Join(", ", values);
|
||||||
|
|
||||||
|
private static string FormatSize(long bytes)
|
||||||
|
{
|
||||||
|
if (bytes >= 1024L * 1024L)
|
||||||
|
return $"{bytes / (1024f * 1024f):F1} MB";
|
||||||
|
if (bytes >= 1024L)
|
||||||
|
return $"{bytes / 1024f:F0} KB";
|
||||||
|
return $"{bytes} B";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Left filter sidebar. The frame (title, reset button, section captions and
|
||||||
|
/// the grouping toggle) lives in
|
||||||
|
/// <c>scenes/components/mod_filter_panel.tscn</c> for visual editing; this
|
||||||
|
/// script fills the two list containers with per-status and per-category
|
||||||
|
/// checkboxes and keeps the shared <see cref="ModFilterState"/> in sync.
|
||||||
|
/// The panel does not own the state and never rebuilds the grid itself — it
|
||||||
|
/// announces every change through <see cref="Changed"/>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ModFilterPanel : VBoxContainer
|
||||||
|
{
|
||||||
|
private ModFilterState _state;
|
||||||
|
private Label _title;
|
||||||
|
private Button _reset;
|
||||||
|
private Label _statusCaption;
|
||||||
|
private Label _categoryCaption;
|
||||||
|
private CheckButton _groupToggle;
|
||||||
|
private VBoxContainer _statusList;
|
||||||
|
private VBoxContainer _categoryList;
|
||||||
|
private readonly Dictionary<ModItemStatus, CheckBox> _statusBoxes = new();
|
||||||
|
private readonly Dictionary<CategoryType, CheckBox> _categoryBoxes = new();
|
||||||
|
|
||||||
|
/// <summary>Raised after any filter or grouping change.</summary>
|
||||||
|
public event Action Changed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised after the reset button cleared the state. The panel cannot
|
||||||
|
/// touch the header's search box, so the screen clears it in response.
|
||||||
|
/// </summary>
|
||||||
|
public event Action ResetRequested;
|
||||||
|
|
||||||
|
/// <summary>Binds the sidebar to the shared filter state; call once, after the scene is built.</summary>
|
||||||
|
public void Bind(ModFilterState state)
|
||||||
|
{
|
||||||
|
_state = state;
|
||||||
|
|
||||||
|
_title = GetNode<Label>("Title");
|
||||||
|
_reset = GetNode<Button>("Reset");
|
||||||
|
_statusCaption = GetNode<Label>("StatusCaption");
|
||||||
|
_categoryCaption = GetNode<Label>("CategoryCaption");
|
||||||
|
_groupToggle = GetNode<CheckButton>("GroupToggle");
|
||||||
|
_statusList = GetNode<VBoxContainer>("StatusList");
|
||||||
|
_categoryList = GetNode<VBoxContainer>("CategoryList");
|
||||||
|
|
||||||
|
_title.Text = TranslationServer.Translate("mod_manager/filters");
|
||||||
|
_reset.Text = TranslationServer.Translate("mod_manager/reset_filters");
|
||||||
|
_statusCaption.Text = TranslationServer.Translate("mod_manager/filter_status");
|
||||||
|
_categoryCaption.Text = TranslationServer.Translate("mod_manager/filter_categories");
|
||||||
|
_groupToggle.Text = TranslationServer.Translate("mod_manager/group_by_category");
|
||||||
|
_groupToggle.ButtonPressed = _state.GroupByCategory;
|
||||||
|
|
||||||
|
_statusCaption.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
_categoryCaption.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
|
||||||
|
_reset.Pressed += OnResetPressed;
|
||||||
|
_groupToggle.Toggled += OnGroupToggled;
|
||||||
|
|
||||||
|
foreach (var status in Enum.GetValues<ModItemStatus>())
|
||||||
|
AddStatusCheck(status);
|
||||||
|
foreach (var category in ModCategoryNames.Ordered)
|
||||||
|
AddCategoryCheck(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Refreshes the numbers in the checkbox captions.</summary>
|
||||||
|
public void RefreshCounts(
|
||||||
|
Dictionary<CategoryType, int> categoryCounts,
|
||||||
|
Dictionary<ModItemStatus, int> statusCounts)
|
||||||
|
{
|
||||||
|
foreach (var pair in _statusBoxes)
|
||||||
|
{
|
||||||
|
var count = statusCounts.GetValueOrDefault(pair.Key);
|
||||||
|
pair.Value.Text = $"{ModStatusNames.Translate(pair.Key)} ({count})";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in _categoryBoxes)
|
||||||
|
{
|
||||||
|
var count = categoryCounts.GetValueOrDefault(pair.Key);
|
||||||
|
pair.Value.Text = $"{ModCategoryNames.Translate(pair.Key)} ({count})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddStatusCheck(ModItemStatus status)
|
||||||
|
{
|
||||||
|
var box = new CheckBox
|
||||||
|
{
|
||||||
|
Text = ModStatusNames.Translate(status),
|
||||||
|
ButtonPressed = _state.Statuses.Contains(status),
|
||||||
|
};
|
||||||
|
box.Toggled += pressed => OnStatusToggled(status, pressed);
|
||||||
|
_statusBoxes[status] = box;
|
||||||
|
_statusList.AddChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddCategoryCheck(CategoryType category)
|
||||||
|
{
|
||||||
|
var box = new CheckBox
|
||||||
|
{
|
||||||
|
Text = ModCategoryNames.Translate(category),
|
||||||
|
ButtonPressed = _state.Categories.Contains(category),
|
||||||
|
};
|
||||||
|
box.Toggled += pressed => OnCategoryToggled(category, pressed);
|
||||||
|
_categoryBoxes[category] = box;
|
||||||
|
_categoryList.AddChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStatusToggled(ModItemStatus status, bool pressed)
|
||||||
|
{
|
||||||
|
if (pressed)
|
||||||
|
_state.Statuses.Add(status);
|
||||||
|
else
|
||||||
|
_state.Statuses.Remove(status);
|
||||||
|
|
||||||
|
Changed?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCategoryToggled(CategoryType category, bool pressed)
|
||||||
|
{
|
||||||
|
if (pressed)
|
||||||
|
_state.Categories.Add(category);
|
||||||
|
else
|
||||||
|
_state.Categories.Remove(category);
|
||||||
|
|
||||||
|
Changed?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGroupToggled(bool pressed)
|
||||||
|
{
|
||||||
|
_state.GroupByCategory = pressed;
|
||||||
|
Changed?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnResetPressed()
|
||||||
|
{
|
||||||
|
_state.Reset();
|
||||||
|
|
||||||
|
foreach (var box in _statusBoxes.Values)
|
||||||
|
box.SetPressedNoSignal(false);
|
||||||
|
foreach (var box in _categoryBoxes.Values)
|
||||||
|
box.SetPressedNoSignal(false);
|
||||||
|
|
||||||
|
ResetRequested?.Invoke();
|
||||||
|
Changed?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One section of the mod grid: an optional category header plus the
|
||||||
|
/// <see cref="HFlowContainer"/> that wraps the tiles. The layout lives in
|
||||||
|
/// <c>scenes/components/mod_grid_group.tscn</c> so the header style and flow
|
||||||
|
/// spacing can be tuned in the editor; the grid instantiates one group per
|
||||||
|
/// category when grouping is enabled, or a single header-less group otherwise.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ModGridGroup : VBoxContainer
|
||||||
|
{
|
||||||
|
private Label _header;
|
||||||
|
private HFlowContainer _flow;
|
||||||
|
|
||||||
|
/// <summary>Container the grid adds <see cref="ModCard"/> tiles to.</summary>
|
||||||
|
public HFlowContainer Flow
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
EnsureNodes();
|
||||||
|
return _flow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets (or hides with null/empty) the section header text.</summary>
|
||||||
|
public void SetHeader(string text)
|
||||||
|
{
|
||||||
|
EnsureNodes();
|
||||||
|
_header.Text = text ?? string.Empty;
|
||||||
|
_header.Visible = !string.IsNullOrEmpty(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureNodes()
|
||||||
|
{
|
||||||
|
if (_flow != null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_header = GetNode<Label>("Header");
|
||||||
|
_flow = GetNode<HFlowContainer>("Flow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Scenes;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Theming;
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scrollable grid of <see cref="ModCard"/> tiles, composed of
|
||||||
|
/// <see cref="ModGridGroup"/> sections (see
|
||||||
|
/// <c>scenes/components/mod_grid_view.tscn</c>). Cards flow inside
|
||||||
|
/// <see cref="HFlowContainer"/>s, so the number of columns adapts to the
|
||||||
|
/// viewport width — the Godot answer to Nexus's responsive auto-fill grid.
|
||||||
|
/// In grouping mode one section per category is created with a header; in flat
|
||||||
|
/// mode a single header-less section is used.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ModGridView : ScrollContainer
|
||||||
|
{
|
||||||
|
private const string GroupScene = "scenes/components/mod_grid_group.tscn";
|
||||||
|
private const string CardScene = "scenes/components/mod_card.tscn";
|
||||||
|
|
||||||
|
private VBoxContainer _host;
|
||||||
|
|
||||||
|
/// <summary>Raised when a card body is clicked.</summary>
|
||||||
|
public event Action<ModCardData> CardClicked;
|
||||||
|
|
||||||
|
/// <summary>Raised when a card's action button is pressed.</summary>
|
||||||
|
public event Action<ModCardData> ActionPressed;
|
||||||
|
|
||||||
|
/// <summary>Raised when a card's enable switch is flipped.</summary>
|
||||||
|
public event Action<ModCardData> EnabledToggled;
|
||||||
|
|
||||||
|
/// <summary>Raised after a card's version selection changes.</summary>
|
||||||
|
public event Action<ModCardData> VersionChanged;
|
||||||
|
|
||||||
|
/// <summary>Raised when any card's "in N collections" cross-link is pressed.</summary>
|
||||||
|
public event Action CollectionsLinkRequested;
|
||||||
|
|
||||||
|
/// <summary>Rebuilds the grid from scratch; called on every filter change.</summary>
|
||||||
|
public void SetItems(IReadOnlyList<ModCardData> items, bool groupByCategory)
|
||||||
|
{
|
||||||
|
if (_host == null)
|
||||||
|
_host = GetNode<VBoxContainer>("Host");
|
||||||
|
|
||||||
|
foreach (var child in _host.GetChildren())
|
||||||
|
child.QueueFree();
|
||||||
|
|
||||||
|
if (items == null || items.Count == 0)
|
||||||
|
{
|
||||||
|
_host.AddChild(BuildEmptyLabel());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupByCategory)
|
||||||
|
{
|
||||||
|
FillGroup(AddGroup(null), items);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var category in ModCategoryNames.Ordered)
|
||||||
|
{
|
||||||
|
var group = new List<ModCardData>();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (ModCategoryNames.Primary(item.Category) == category)
|
||||||
|
group.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var header = $"{ModCategoryNames.Translate(category)} · {group.Count}";
|
||||||
|
FillGroup(AddGroup(header), group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ModGridGroup AddGroup(string header)
|
||||||
|
{
|
||||||
|
var group = TscnScenes.LoadAndBuild(GroupScene) as ModGridGroup;
|
||||||
|
if (group == null)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MOD_MANAGER", $"Grid: failed to build '{GroupScene}'", LogLevel.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.SetHeader(header);
|
||||||
|
_host.AddChild(group);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FillGroup(ModGridGroup group, IReadOnlyList<ModCardData> items)
|
||||||
|
{
|
||||||
|
if (group == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var data in items)
|
||||||
|
{
|
||||||
|
var card = TscnScenes.LoadAndBuild(CardScene) as ModCard;
|
||||||
|
if (card == null)
|
||||||
|
{
|
||||||
|
GameLogger.Log("MOD_MANAGER", $"Grid: failed to build '{CardScene}'", LogLevel.Error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.Flow.AddChild(card);
|
||||||
|
card.Clicked += OnCardClicked;
|
||||||
|
card.ActionPressed += OnCardActionPressed;
|
||||||
|
card.EnabledToggled += OnCardEnabledToggled;
|
||||||
|
card.VersionChanged += OnCardVersionChanged;
|
||||||
|
card.CollectionsLinkRequested += () => CollectionsLinkRequested?.Invoke();
|
||||||
|
card.Init(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Label BuildEmptyLabel()
|
||||||
|
{
|
||||||
|
return UiKit.EmptyState(TranslationServer.Translate("mod_manager/no_results"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCardClicked(ModCardData data) => CardClicked?.Invoke(data);
|
||||||
|
|
||||||
|
private void OnCardActionPressed(ModCardData data) => ActionPressed?.Invoke(data);
|
||||||
|
|
||||||
|
private void OnCardEnabledToggled(ModCardData data) => EnabledToggled?.Invoke(data);
|
||||||
|
|
||||||
|
private void OnCardVersionChanged(ModCardData data) => VersionChanged?.Invoke(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
using Cthangover.Mods.Interface.Theming;
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Compatibility facade over the central <see cref="CthangoverTheme"/> and
|
||||||
|
/// <see cref="UiTokens"/>. The mod manager once carried its own Nexus-like
|
||||||
|
/// palette; now every colour is a design token and the shared theme cascades
|
||||||
|
/// from the screen root. Public names are preserved so existing widgets keep
|
||||||
|
/// compiling, but each one is an alias or a thin wrapper around the core
|
||||||
|
/// theming API. New code should reference <see cref="UiTokens"/>,
|
||||||
|
/// <see cref="CthangoverTheme"/> and <see cref="UiKit"/> directly.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModManagerTheme
|
||||||
|
{
|
||||||
|
/// <summary>Page background.</summary>
|
||||||
|
public static readonly Color BgBase = UiTokens.Surface0;
|
||||||
|
|
||||||
|
/// <summary>Card and sidebar surface.</summary>
|
||||||
|
public static readonly Color SurfaceLow = UiTokens.Surface1;
|
||||||
|
|
||||||
|
/// <summary>Raised control surface.</summary>
|
||||||
|
public static readonly Color SurfaceRaised = UiTokens.Surface3;
|
||||||
|
|
||||||
|
/// <summary>Primary text.</summary>
|
||||||
|
public static readonly Color TextStrong = UiTokens.InkBright;
|
||||||
|
|
||||||
|
/// <summary>Secondary text.</summary>
|
||||||
|
public static readonly Color TextMuted = UiTokens.Muted;
|
||||||
|
|
||||||
|
/// <summary>Hairline border.</summary>
|
||||||
|
public static readonly Color BorderWeak = UiTokens.Line;
|
||||||
|
|
||||||
|
/// <summary>Control border.</summary>
|
||||||
|
public static readonly Color BorderStrong = UiTokens.LineStrong;
|
||||||
|
|
||||||
|
/// <summary>Gold accent.</summary>
|
||||||
|
public static readonly Color Accent = UiTokens.Accent;
|
||||||
|
|
||||||
|
/// <summary>Text on accent buttons.</summary>
|
||||||
|
public static readonly Color AccentText = UiTokens.Surface0;
|
||||||
|
|
||||||
|
/// <summary>Danger red for destructive actions.</summary>
|
||||||
|
public static readonly Color Danger = UiTokens.Danger;
|
||||||
|
|
||||||
|
/// <summary>Returns the single shared theme; the manager no longer owns a private one.</summary>
|
||||||
|
public static Theme Create() => CthangoverTheme.Get();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Style for a mod tile. The negative top/left content margins shift the
|
||||||
|
/// cover image 1px up and make it 1px wider so it sits flush over the
|
||||||
|
/// card border. A conflicting catalog variant gets a danger border.
|
||||||
|
/// </summary>
|
||||||
|
public static StyleBoxFlat CreateCardStyle(bool conflict)
|
||||||
|
{
|
||||||
|
var style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface1, conflict ? UiTokens.Danger : UiTokens.Line);
|
||||||
|
style.ContentMarginTop = -1;
|
||||||
|
style.ContentMarginLeft = -1;
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies the shared primary accent variation to a button.</summary>
|
||||||
|
public static void ApplyPrimaryButton(Button button) => CthangoverTheme.ApplyPrimary(button);
|
||||||
|
|
||||||
|
/// <summary>Applies the shared danger variation to a button.</summary>
|
||||||
|
public static void ApplyDangerButton(Button button) => CthangoverTheme.ApplyDanger(button);
|
||||||
|
|
||||||
|
/// <summary>Creates a flat style box with an optional border and padding.</summary>
|
||||||
|
public static StyleBoxFlat MakeBox(Color background, Color border, int radius, int paddingH, int paddingV)
|
||||||
|
{
|
||||||
|
var style = UiKit.Frame(radius, background, border);
|
||||||
|
style.ContentMarginLeft = paddingH;
|
||||||
|
style.ContentMarginRight = paddingH;
|
||||||
|
style.ContentMarginTop = paddingV;
|
||||||
|
style.ContentMarginBottom = paddingV;
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Deleting a player's game collection, shared by the gallery card and the
|
||||||
|
/// scheme editor header so both ask the same question. Only the player's
|
||||||
|
/// own collections can go: a collection a mod or the catalog ships would
|
||||||
|
/// come back on the next merge (its edited version is dropped with
|
||||||
|
/// "Revert to built-in" instead). Deleting touches only
|
||||||
|
/// <c>user://collections.json</c> — the mods of the collection stay
|
||||||
|
/// installed and its saves stay on disk (they reappear if a collection
|
||||||
|
/// with the same id comes back), which the confirmation spells out.
|
||||||
|
/// </summary>
|
||||||
|
public static class CollectionDeletion
|
||||||
|
{
|
||||||
|
/// <summary>True for a player-made collection (saved or still a draft) that no mod or catalog ships.</summary>
|
||||||
|
public static bool CanDelete(ModCollection collection)
|
||||||
|
{
|
||||||
|
return collection != null
|
||||||
|
&& collection.Origin == ModCollectionOrigin.User
|
||||||
|
&& !ModCollectionStore.HasShippedVersion(collection.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks for confirmation under <paramref name="host"/>; on "delete"
|
||||||
|
/// removes the saved collection and calls <paramref name="deleted"/>
|
||||||
|
/// (the caller also drops its unsaved draft and refreshes its view).
|
||||||
|
/// </summary>
|
||||||
|
public static void Confirm(Node host, ModCollection collection, Action deleted)
|
||||||
|
{
|
||||||
|
if (host == null || !CanDelete(collection))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var name = CollectionText.Resolve(collection.GetLocalizedName(TranslationServer.GetLocale()));
|
||||||
|
var dialog = new ConfirmationDialog
|
||||||
|
{
|
||||||
|
Title = TranslationServer.Translate("mod_manager/collection_delete"),
|
||||||
|
DialogText = string.Format(TranslationServer.Translate("mod_manager/collection_delete_confirm"),
|
||||||
|
name, SaveService.ListSaves(collection.Id).Count),
|
||||||
|
OkButtonText = TranslationServer.Translate("mod_manager/collection_delete"),
|
||||||
|
CancelButtonText = TranslationServer.Translate("mod_manager/cancel"),
|
||||||
|
Exclusive = true,
|
||||||
|
};
|
||||||
|
dialog.Confirmed += () =>
|
||||||
|
{
|
||||||
|
ModCollectionStore.RemoveUser(collection.Id);
|
||||||
|
dialog.QueueFree();
|
||||||
|
deleted?.Invoke();
|
||||||
|
};
|
||||||
|
dialog.Canceled += dialog.QueueFree;
|
||||||
|
host.AddChild(dialog);
|
||||||
|
dialog.PopupCentered();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Locale keys and node colors for <see cref="ModCollectionNodeState"/>,
|
||||||
|
/// following the same pattern as <see cref="ModStatusNames"/> so the
|
||||||
|
/// collections graph reads like the rest of the manager: green — ready to
|
||||||
|
/// play, amber — needs an action (enable, resolve a version pin), blue —
|
||||||
|
/// downloadable from the catalog, red — impossible to resolve.
|
||||||
|
/// </summary>
|
||||||
|
public static class CollectionStateNames
|
||||||
|
{
|
||||||
|
/// <summary>Localized state label shown inside graph nodes and in the details panel.</summary>
|
||||||
|
public static string Translate(ModCollectionNodeState state)
|
||||||
|
=> TranslationServer.Translate(Key(state));
|
||||||
|
|
||||||
|
/// <summary>Translation key of a state.</summary>
|
||||||
|
public static string Key(ModCollectionNodeState state) => state switch
|
||||||
|
{
|
||||||
|
ModCollectionNodeState.Installed => "mod_manager/collection_state_installed",
|
||||||
|
ModCollectionNodeState.Disabled => "mod_manager/collection_state_disabled",
|
||||||
|
ModCollectionNodeState.InCatalog => "mod_manager/collection_state_catalog",
|
||||||
|
ModCollectionNodeState.VersionMismatch => "mod_manager/collection_state_mismatch",
|
||||||
|
_ => "mod_manager/collection_state_missing",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Titlebar/badge color for a state.</summary>
|
||||||
|
public static Color Color(ModCollectionNodeState state) => state switch
|
||||||
|
{
|
||||||
|
ModCollectionNodeState.Installed => UiTokens.Success,
|
||||||
|
ModCollectionNodeState.Disabled => UiTokens.Warning,
|
||||||
|
ModCollectionNodeState.InCatalog => UiTokens.Info,
|
||||||
|
ModCollectionNodeState.VersionMismatch => UiTokens.Warning,
|
||||||
|
_ => UiTokens.Danger,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>Locale keys and styling hints for <see cref="ModActionKind"/>.</summary>
|
||||||
|
public static class ModActionNames
|
||||||
|
{
|
||||||
|
/// <summary>Localized action button caption.</summary>
|
||||||
|
public static string Translate(ModActionKind kind)
|
||||||
|
=> TranslationServer.Translate(Key(kind));
|
||||||
|
|
||||||
|
/// <summary>Translation key of an action.</summary>
|
||||||
|
public static string Key(ModActionKind kind) => kind switch
|
||||||
|
{
|
||||||
|
ModActionKind.Install => "mod_manager/action_install",
|
||||||
|
ModActionKind.Update => "mod_manager/action_update",
|
||||||
|
ModActionKind.Downgrade => "mod_manager/action_downgrade",
|
||||||
|
ModActionKind.Reinstall => "mod_manager/action_reinstall",
|
||||||
|
ModActionKind.Delete => "mod_manager/action_delete",
|
||||||
|
ModActionKind.Enable => "mod_manager/action_enable",
|
||||||
|
_ => "mod_manager/action_disable",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>True for destructive actions that deserve the danger button style.</summary>
|
||||||
|
public static bool IsDanger(ModActionKind kind) => kind == ModActionKind.Delete;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Presentation helpers for catalog provenance. The manager shows where a mod
|
||||||
|
/// comes from without cluttering the tile: a short host label plus the full
|
||||||
|
/// URL list in the tooltip, and a "+N" marker when several catalogs publish
|
||||||
|
/// the same build.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModCatalogSource
|
||||||
|
{
|
||||||
|
/// <summary>First source URL, or null when there are none.</summary>
|
||||||
|
public static string FirstUrl(IReadOnlyList<string> urls)
|
||||||
|
=> urls != null && urls.Count > 0 ? urls[0] : null;
|
||||||
|
|
||||||
|
/// <summary>Host of a URL (fallback: the raw string when it does not parse).</summary>
|
||||||
|
public static string Host(string url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host))
|
||||||
|
return uri.Host;
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Short label: host plus "+N" for extra catalogs, or null when unknown.</summary>
|
||||||
|
public static string Label(IReadOnlyList<string> urls)
|
||||||
|
{
|
||||||
|
var first = FirstUrl(urls);
|
||||||
|
if (first == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var host = Host(first);
|
||||||
|
var extra = urls.Count - 1;
|
||||||
|
return extra > 0 ? $"{host} +{extra}" : host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Newline-joined full URL list for tooltips.</summary>
|
||||||
|
public static string Tooltip(IReadOnlyList<string> urls)
|
||||||
|
=> urls == null || urls.Count == 0 ? null : string.Join("\n", urls);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Cthangover.Core.Mods;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Presentation names for <see cref="CategoryType"/>. Kept inside the
|
||||||
|
/// mod_manager mod (keys prefixed <c>mod_manager/category_</c>) so the manager
|
||||||
|
/// does not depend on the interface mod's locale for its filter sidebar.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModCategoryNames
|
||||||
|
{
|
||||||
|
/// <summary>Display order of category filters and group headers.</summary>
|
||||||
|
public static readonly CategoryType[] Ordered =
|
||||||
|
{
|
||||||
|
CategoryType.Story,
|
||||||
|
CategoryType.Characters,
|
||||||
|
CategoryType.Gameplay,
|
||||||
|
CategoryType.Visual,
|
||||||
|
CategoryType.Audio,
|
||||||
|
CategoryType.UI,
|
||||||
|
CategoryType.Locale,
|
||||||
|
CategoryType.Library,
|
||||||
|
CategoryType.Engine,
|
||||||
|
CategoryType.Overhaul,
|
||||||
|
CategoryType.Tools,
|
||||||
|
CategoryType.Editor,
|
||||||
|
CategoryType.Debug,
|
||||||
|
CategoryType.Other,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// First single category of a (possibly combined) flags value, used to
|
||||||
|
/// place a mod into exactly one grid group and one count bucket per
|
||||||
|
/// category it belongs to.
|
||||||
|
/// </summary>
|
||||||
|
public static CategoryType Primary(CategoryType category)
|
||||||
|
{
|
||||||
|
foreach (var candidate in Ordered)
|
||||||
|
{
|
||||||
|
if ((category & candidate) != 0)
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return CategoryType.Other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Localized category name for filters and group headers.</summary>
|
||||||
|
public static string Translate(CategoryType category)
|
||||||
|
=> Godot.TranslationServer.Translate(Key(category));
|
||||||
|
|
||||||
|
/// <summary>Translation key of a single (non-combined) category.</summary>
|
||||||
|
public static string Key(CategoryType category) => category switch
|
||||||
|
{
|
||||||
|
CategoryType.Story => "mod_manager/category_story",
|
||||||
|
CategoryType.Characters => "mod_manager/category_characters",
|
||||||
|
CategoryType.Gameplay => "mod_manager/category_gameplay",
|
||||||
|
CategoryType.Visual => "mod_manager/category_visual",
|
||||||
|
CategoryType.Audio => "mod_manager/category_audio",
|
||||||
|
CategoryType.UI => "mod_manager/category_ui",
|
||||||
|
CategoryType.Locale => "mod_manager/category_locale",
|
||||||
|
CategoryType.Library => "mod_manager/category_library",
|
||||||
|
CategoryType.Engine => "mod_manager/category_engine",
|
||||||
|
CategoryType.Overhaul => "mod_manager/category_overhaul",
|
||||||
|
CategoryType.Tools => "mod_manager/category_tools",
|
||||||
|
CategoryType.Editor => "mod_manager/category_editor",
|
||||||
|
CategoryType.Debug => "mod_manager/category_debug",
|
||||||
|
_ => "mod_manager/category_other",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shared texts for the mod-to-collection cross links required by the hub
|
||||||
|
/// mockup: mod tiles and the detail page show a clickable "in N
|
||||||
|
/// collections" badge that jumps to the collections tab. Centralized so
|
||||||
|
/// the card and the detail page always render the same pluralized string.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModCollectionLinks
|
||||||
|
{
|
||||||
|
/// <summary>Localized "in N collection(s)" label for the given count; empty when the mod belongs to no game.</summary>
|
||||||
|
public static string BadgeText(int count)
|
||||||
|
{
|
||||||
|
if (count <= 0)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var key = count == 1
|
||||||
|
? "mod_manager/hub_in_one_collection"
|
||||||
|
: "mod_manager/hub_in_many_collections";
|
||||||
|
return string.Format(TranslationServer.Translate(key), count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Mods.Caches;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Loads preview images for mod tiles and the detail page. Local mods use
|
||||||
|
/// their bundled <c>mod.png</c> / <c>mod.jpg</c>; catalog-only mods fall back
|
||||||
|
/// to <see cref="ModIconCache"/>, which downloads and caches the entry's
|
||||||
|
/// <c>icon_url</c> under <c>user://mod_cache/icons</c>. Returning null is a
|
||||||
|
/// normal outcome — the card then keeps its flat placeholder.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModPreviewLoader
|
||||||
|
{
|
||||||
|
/// <summary>Reads the mod's bundled preview image, or null when absent/unreadable.</summary>
|
||||||
|
public static Texture2D LoadLocal(ModInfo info)
|
||||||
|
{
|
||||||
|
var provider = info?.FileProvider;
|
||||||
|
if (provider == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
byte[] data = null;
|
||||||
|
if (provider.FileExists("mod.png"))
|
||||||
|
data = provider.ReadFileBinary("mod.png");
|
||||||
|
else if (provider.FileExists("mod.jpg"))
|
||||||
|
data = provider.ReadFileBinary("mod.jpg");
|
||||||
|
|
||||||
|
if (data == null || data.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var image = new Image();
|
||||||
|
Error error;
|
||||||
|
if (data[0] == 0x89)
|
||||||
|
error = image.LoadPngFromBuffer(data);
|
||||||
|
else if (data[0] == 0xFF)
|
||||||
|
error = image.LoadJpgFromBuffer(data);
|
||||||
|
else
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (error != Error.Ok)
|
||||||
|
{
|
||||||
|
image.Dispose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var texture = ImageTexture.CreateFromImage(image);
|
||||||
|
image.Dispose();
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asynchronously resolves a catalog icon; the callback may fire later
|
||||||
|
/// (network) or immediately (memory/disk cache).
|
||||||
|
/// </summary>
|
||||||
|
public static void LoadRemote(string modId, string iconUrl, Action<Texture2D> onLoaded)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(iconUrl))
|
||||||
|
return;
|
||||||
|
|
||||||
|
ModIconCache.GetOrDownload(modId, iconUrl, onLoaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Mods.ModManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Locale keys, badge colors and sort ranks for <see cref="ModItemStatus"/>.
|
||||||
|
/// Colors are semantic <see cref="UiTokens"/> entries shared with the rest of
|
||||||
|
/// the UI: success installed, warning update, info downgrade, danger locally
|
||||||
|
/// modified, muted disabled, faint not installed.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModStatusNames
|
||||||
|
{
|
||||||
|
/// <summary>Localized status label shown on tiles and the detail page.</summary>
|
||||||
|
public static string Translate(ModItemStatus status)
|
||||||
|
=> TranslationServer.Translate(Key(status));
|
||||||
|
|
||||||
|
/// <summary>Translation key of a status.</summary>
|
||||||
|
public static string Key(ModItemStatus status) => status switch
|
||||||
|
{
|
||||||
|
ModItemStatus.Installed => "mod_manager/status_installed",
|
||||||
|
ModItemStatus.UpdateAvailable => "mod_manager/status_update",
|
||||||
|
ModItemStatus.DowngradeAvailable => "mod_manager/status_downgrade",
|
||||||
|
ModItemStatus.ModifiedLocally => "mod_manager/status_modified",
|
||||||
|
ModItemStatus.Disabled => "mod_manager/status_disabled",
|
||||||
|
_ => "mod_manager/status_not_installed",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Badge color for a status.</summary>
|
||||||
|
public static Color Color(ModItemStatus status) => status switch
|
||||||
|
{
|
||||||
|
ModItemStatus.Installed => UiTokens.Success,
|
||||||
|
ModItemStatus.UpdateAvailable => UiTokens.Warning,
|
||||||
|
ModItemStatus.DowngradeAvailable => UiTokens.Info,
|
||||||
|
ModItemStatus.ModifiedLocally => UiTokens.Danger,
|
||||||
|
ModItemStatus.Disabled => UiTokens.Muted,
|
||||||
|
_ => UiTokens.Faint,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rank used by <see cref="ModSortMode.Status"/>: highlights actionable
|
||||||
|
/// states (update, modified) before plain installed and catalog-only mods.
|
||||||
|
/// </summary>
|
||||||
|
public static int SortRank(ModItemStatus status) => status switch
|
||||||
|
{
|
||||||
|
ModItemStatus.UpdateAvailable => 0,
|
||||||
|
ModItemStatus.ModifiedLocally => 1,
|
||||||
|
ModItemStatus.Installed => 2,
|
||||||
|
ModItemStatus.DowngradeAvailable => 3,
|
||||||
|
ModItemStatus.Disabled => 4,
|
||||||
|
_ => 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 624 B |
Reference in new issue
Block a user