Tool windows redesign and the Novel Editor workspace

Tool windows:
- Every editor (scenario, scene builder, map, interactive objects,
  lighting, catalog export) is rebuilt on the interface ToolWindow chrome
  and the denser ToolTheme: header with title/close, status bar, Ctrl+S,
  dirty-state confirmation.
- Shared building blocks in src/Common: ToolUi (rows, buttons, sections),
  CollapsibleSection, FilteredList, ModFileSelector, DragHandle and
  BackgroundCanvas (scene background with pan/zoom).
- The map, interactive and light editors draw on canvases
  (MapEditorCanvas, InteractiveEditorCanvas, LightEditorCanvas) instead of
  one Control "handle" node per object; the *Handle classes are removed.
- Scenario editor: tab data model (TabData), automatic graph layout
  (ScenarioGraphLayout), file tree/info/thumbnail panel updates.
- Services: ModFileService (mod file listing and writing),
  SceneBackgroundService; MapFileService/ModResourceService follow.
- Catalog export: per-language name/description editing.
- Locale (en/ru) regrouped under tools/*; unused keys removed.

Novel Editor:
- collections/novel_editor.json: a "game" whose entry scene is an
  authoring workspace over the novel engine and the story content.
- NovelEditorHub (scenes/novel_editor_hub.tscn) lists every installed
  tool through IToolProvider.AddMenuButton; ToolsInitializer registers it
  as the logical scene "novel_editor".
This commit is contained in:
cthangover committed 2026-10-17 14:29:14 +00:00
1 parent 578ba7c5d5
commit 8742aa3da5
45 files changed
+4718 -3463

No files matched your search

+21 -32
View File
@@ -2,42 +2,34 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>State of one open scenario file tab: identity, saved text and dirty flag.</summary>
public sealed class TabData
{
public string Key;
public string ModId;
public string RelativePath;
public string FileName;
public string SavedText;
public bool IsDirty;
}
/// <summary>
/// Tabbed editor area of the scenario editor: a permanent graph tab plus
/// one text tab per opened .scenario file, with dirty tracking and a
/// monospace syntax-highlighted code editor.
/// monospace syntax-highlighted code editor with line numbers. Only one
/// <see cref="CodeEdit"/> exists; switching tabs swaps its text with the
/// tab's <see cref="TabData.Buffer"/>, which therefore holds unsaved edits
/// of background tabs.
/// </summary>
public partial class ScenarioEditTabs : VBoxContainer
{
private const int GraphTabIndex = 0;
private TabBar _tabBar;
private TextEdit _textEdit;
private CodeEdit _textEdit;
private ScenarioGraphView _graphView;
private Font _monospaceFont;
private readonly List<TabData> _tabs = new();
private int _activeTabIndex = -1;
private bool _suppressTextChanged;
public TextEdit CodeEditor => _textEdit;
public CodeEdit CodeEditor => _textEdit;
public string CurrentText => _textEdit.Text;
public string CurrentKey =>
_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count ? _tabs[_activeTabIndex].Key : null;
@@ -54,10 +46,12 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
public event Action<bool> DirtyChanged;
public event Action<string> GraphFileRequested;
public ScenarioEditTabs(ScenarioGraphView graphView, Font monospaceFont)
/// <summary>Raised on every user edit of the active text tab.</summary>
public event Action BufferChanged;
public ScenarioEditTabs(ScenarioGraphView graphView)
{
_graphView = graphView;
_monospaceFont = monospaceFont;
SizeFlagsHorizontal = SizeFlags.ExpandFill;
SizeFlagsVertical = SizeFlags.ExpandFill;
@@ -69,15 +63,8 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_tabBar.AddTab(TranslationServer.Translate("tools/scenario_editor/tab_graph"));
AddChild(_tabBar);
_textEdit = new TextEdit
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ExpandFill,
Editable = true,
SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter(),
WrapMode = TextEdit.LineWrappingMode.None
};
_textEdit.AddThemeFontOverride("font", _monospaceFont);
_textEdit = ToolUi.Code();
_textEdit.SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter();
_textEdit.TextChanged += OnTextChanged;
AddChild(_textEdit);
@@ -115,12 +102,13 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
ModId = modId,
RelativePath = relativePath,
FileName = Path.GetFileNameWithoutExtension(relativePath),
SavedText = text,
Buffer = text,
IsDirty = false
};
_tabs.Add(tab);
_tabBar.AddTab(tab.FileName);
_tabBar.SetTabTooltip(_tabBar.TabCount - 1, ScenarioFileService.GetDisplayPath(modId, relativePath));
SwitchToTextTab(_tabs.Count - 1);
}
@@ -137,7 +125,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
return;
var tab = _tabs[_activeTabIndex];
tab.SavedText = _textEdit.Text;
tab.Buffer = _textEdit.Text;
if (tab.IsDirty)
{
tab.IsDirty = false;
@@ -218,16 +206,16 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
return;
if (_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count)
_tabs[_activeTabIndex].SavedText = _textEdit.Text;
_tabs[_activeTabIndex].Buffer = _textEdit.Text;
_activeTabIndex = index;
_tabBar.CurrentTab = index + 1;
var tab = _tabs[index];
SetTextSilent(tab.SavedText);
SetTextSilent(tab.Buffer);
TextTabSelected?.Invoke(tab.SavedText);
TextTabSelected?.Invoke(tab.Buffer);
}
private void CloseTextTab(int index)
@@ -296,6 +284,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
UpdateTabTitle(_activeTabIndex);
UpdateGlobalDirty();
}
BufferChanged?.Invoke();
}
private void UpdateTabTitle(int index)
@@ -315,7 +304,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private void SaveActiveTextEditContent()
{
if (_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count)
_tabs[_activeTabIndex].SavedText = _textEdit.Text;
_tabs[_activeTabIndex].Buffer = _textEdit.Text;
}
private void OnGraphScenarioFileRequested(string filePath)
+99 -130
View File
@@ -2,32 +2,38 @@ using System;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// Tool window hosting the scenario editor: a file tree of every mod's
/// .scenario files, tabbed text/graph editors, metadata and thumbnail
/// panels, plus a play-test mode that runs the current scenario in-game.
/// Tool window hosting the scenario editor: a searchable tree of every
/// mod's .scenario files, tabbed code/graph editors, a metadata panel that
/// follows the text as it is typed, background thumbnails, plus a
/// play-test mode that runs the current scenario in-game.
/// <para/>
/// Shortcuts: Ctrl+S saves the active file, F5 play-tests it.
/// </summary>
public partial class ScenarioEditorWindow : ToolWindow
{
private const double InfoRefreshDelay = 0.4;
private Control _editLayout;
private Control _playLayout;
private ScenarioFileTreePanel _fileTreePanel;
private ScenarioEditTabs _editTabs;
private ScenarioInfoPanel _infoPanel;
private ScenarioThumbnailPanel _thumbPanel;
private Timer _infoTimer;
private Button _saveBtn;
private Button _openFileBtn;
private Button _playBtn;
private Label _filePathLabel;
private Label _statusLabel;
private bool _playActive;
private CanvasLayer _stopOverlay;
@@ -36,6 +42,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
CthangoverTheme.Apply(this);
BuildUI();
SetHint(ToolUi.Tr("tools/scenario_editor/hint"));
}
/// <summary>Opens with the graph view so the editor starts on the cross-scene map.</summary>
@@ -44,10 +51,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_editTabs.SetViewToGraph();
}
protected override void Cleanup()
{
}
/// <summary>Returns from play-test mode as soon as the in-game test play ends.</summary>
public override void _Process(double delta)
{
@@ -60,8 +63,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_editLayout = new Control
{
AnchorRight = 1f,
AnchorBottom = 1f,
Visible = true
AnchorBottom = 1f
};
AddChild(_editLayout);
@@ -71,111 +73,70 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
var toolbar = CreateToolbar();
outerVBox.AddChild(toolbar);
_saveBtn = new Button
{
Text = TranslationServer.Translate("tools/scenario_editor/save"),
Disabled = true
};
_saveBtn.Pressed += OnSavePressed;
_saveBtn = ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/common/save_tip");
toolbar.AddChild(_saveBtn);
_openFileBtn = new Button
{
Text = TranslationServer.Translate("tools/scenario_editor/open_file"),
Disabled = true
};
_openFileBtn.Pressed += OnOpenFilePressed;
toolbar.AddChild(_openFileBtn);
_playBtn = new Button
{
Text = TranslationServer.Translate("tools/scenario_editor/play"),
TooltipText = TranslationServer.Translate("tools/scenario_editor/play_tooltip"),
Disabled = true
};
_playBtn.Pressed += OnPlayPressed;
_playBtn = ToolUi.Button("tools/scenario_editor/play", OnPlayPressed, ButtonKind.Secondary, "tools/scenario_editor/play_tooltip");
toolbar.AddChild(_playBtn);
_openFileBtn = ToolUi.Button("tools/scenario_editor/open_file", OnOpenFilePressed, ButtonKind.Secondary, "tools/scenario_editor/open_file_tip");
toolbar.AddChild(_openFileBtn);
toolbar.AddChild(ToolUi.Divider());
toolbar.AddChild(ToolUi.Button("tools/scenario_editor/rescan", OnRescanPressed, ButtonKind.Secondary, "tools/scenario_editor/rescan_tip"));
_filePathLabel = new Label { Text = "" };
_filePathLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_filePathLabel = new Label
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis
};
_filePathLabel.AddThemeFontOverride("font", ToolUi.Mono());
_filePathLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_filePathLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
toolbar.AddChild(_filePathLabel);
var mainSplit = new HSplitContainer();
mainSplit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
mainSplit.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
outerVBox.AddChild(mainSplit);
var leftPanel = new PanelContainer
var mainSplit = new HSplitContainer
{
CustomMinimumSize = new Vector2(340, 0),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outerVBox.AddChild(mainSplit);
var leftVBox = new VBoxContainer { SizeFlagsVertical = Control.SizeFlags.ExpandFill };
var leftPanel = CreateSidebar(leftVBox, 320);
mainSplit.AddChild(leftPanel);
var leftVBox = new VBoxContainer();
leftPanel.AddChild(leftVBox);
var tabsContainer = new TabContainer();
tabsContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
tabsContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
var tabsContainer = new TabContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(0, 420)
};
leftVBox.AddChild(tabsContainer);
_fileTreePanel = (ScenarioFileTreePanel)TscnScenes.LoadAndBuild("scenes/scenario_file_tree_panel.tscn");
_fileTreePanel.FileSelected += OnFileSelected;
tabsContainer.AddChild(_fileTreePanel);
tabsContainer.SetTabTitle(0, TranslationServer.Translate("tools/scenario_editor/tab_files"));
tabsContainer.SetTabTitle(0, ToolUi.Tr("tools/scenario_editor/tab_files"));
_infoPanel = (ScenarioInfoPanel)TscnScenes.LoadAndBuild("scenes/scenario_info_panel.tscn");
tabsContainer.AddChild(_infoPanel);
tabsContainer.SetTabTitle(1, TranslationServer.Translate("tools/scenario_editor/tab_info"));
leftVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/scenario_editor/bg_previews") + ":" });
tabsContainer.SetTabTitle(1, ToolUi.Tr("tools/scenario_editor/tab_info"));
leftVBox.AddChild(ToolUi.Section("tools/scenario_editor/bg_previews"));
_thumbPanel = (ScenarioThumbnailPanel)TscnScenes.LoadAndBuild("scenes/scenario_thumbnail_panel.tscn");
leftVBox.AddChild(_thumbPanel);
var rightPanel = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
mainSplit.AddChild(rightPanel);
_editTabs = new ScenarioEditTabs(new ScenarioGraphView(), GetMonospaceFont());
_editTabs = new ScenarioEditTabs(new ScenarioGraphView());
_editTabs.TextTabSelected += OnTextTabSelected;
_editTabs.GraphTabSelected += OnGraphTabSelected;
_editTabs.DirtyChanged += OnDirtyChanged;
_editTabs.GraphFileRequested += OnGraphFileRequested;
rightPanel.AddChild(_editTabs);
_editTabs.BufferChanged += () => _infoTimer.Start();
mainSplit.AddChild(_editTabs);
var statusBar = new HBoxContainer();
outerVBox.AddChild(statusBar);
_statusLabel = new Label { Text = "" };
statusBar.AddChild(_statusLabel);
_playLayout = new Control
{
AnchorRight = 1f,
AnchorBottom = 1f,
Visible = false
};
AddChild(_playLayout);
var stopContainer = new HBoxContainer();
stopContainer.Alignment = BoxContainer.AlignmentMode.Center;
stopContainer.AnchorRight = 0.5f;
stopContainer.AnchorLeft = 0.5f;
stopContainer.OffsetTop = 8;
_playLayout.AddChild(stopContainer);
var stopBtn = new Button
{
Text = TranslationServer.Translate("tools/scenario_editor/stop"),
CustomMinimumSize = new Vector2(120, 40)
};
stopBtn.Pressed += OnStopPressed;
stopContainer.AddChild(stopBtn);
_infoTimer = new Timer { OneShot = true, WaitTime = InfoRefreshDelay };
_infoTimer.Timeout += () => _infoPanel.Update(_editTabs.CurrentText);
AddChild(_infoTimer);
}
private void OnFileSelected(string filePath)
@@ -188,15 +149,19 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_fileTreePanel.SelectFile(filePath);
}
private void OnRescanPressed()
{
_fileTreePanel.Reload();
_editTabs.RefreshGraph();
SetStatus(ToolUi.Tr("tools/scenario_editor/rescanned"));
}
private void OnTextTabSelected(string text)
{
_filePathLabel.Text = _editTabs.CurrentKey != null
var hasFile = _editTabs.CurrentKey != null;
_filePathLabel.Text = hasFile
? ScenarioFileService.GetDisplayPath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath) : "";
_saveBtn.Disabled = false;
_openFileBtn.Disabled = false;
_playBtn.Disabled = false;
_statusLabel.Text = _editTabs.IsCurrentDirty
? TranslationServer.Translate("tools/scenario_editor/status_modified") : "";
SetFileButtonsEnabled(hasFile);
_infoPanel.Update(text);
_thumbPanel.Load(text);
}
@@ -204,26 +169,38 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private void OnGraphTabSelected()
{
_filePathLabel.Text = "";
_saveBtn.Disabled = true;
_openFileBtn.Disabled = true;
_playBtn.Disabled = true;
_statusLabel.Text = "";
SetFileButtonsEnabled(false);
_infoPanel.Clear();
_thumbPanel.Clear();
_editTabs.RefreshGraph();
}
private void SetFileButtonsEnabled(bool enabled)
{
_saveBtn.Disabled = !enabled;
_openFileBtn.Disabled = !enabled;
_playBtn.Disabled = !enabled;
}
private void OnDirtyChanged(bool anyDirty)
{
if (anyDirty)
{
SetDirty();
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/status_modified");
}
else
{
MarkClean();
}
}
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => OnSavePressed();
/// <summary>F5 play-tests the active scenario.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (key.Keycode != Key.F5 || !_editTabs.HasActiveFile)
return false;
OnPlayPressed();
return true;
}
private void OnSavePressed()
@@ -238,22 +215,20 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
if (!ScenarioFileService.WriteAllText(modId, relativePath, text))
{
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip");
SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"), ToolStatusKind.Error);
return;
}
_editTabs.SaveCurrentFile();
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/status_saved");
SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), ScenarioFileService.GetDisplayPath(modId, relativePath)), ToolStatusKind.Ok);
_infoPanel.Update(text);
_thumbPanel.Load(text);
_editTabs.RefreshGraph();
GameLogger.Log("SCENARIO_EDITOR", $"Saved: {modId}/{relativePath}");
}
catch (Exception ex)
{
GameLogger.Log("SCENARIO_EDITOR", $"Save failed: {ex.Message}", LogLevel.Error);
_statusLabel.Text = string.Format(
TranslationServer.Translate("tools/scenario_editor/save_error"), ex.Message);
SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"), ex.Message), ToolStatusKind.Error);
}
}
@@ -265,14 +240,16 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
var path = ScenarioFileService.GetAbsolutePath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath);
if (string.IsNullOrEmpty(path))
{
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip");
SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"), ToolStatusKind.Error);
return;
}
GD.Print($"[ScenarioEditor] Opening: {path}");
var err = OS.ShellOpen(path);
if (err != Error.Ok)
GameLogger.Log("SCENARIO_EDITOR", $"ShellOpen error: {err}", LogLevel.Error);
{
GameLogger.Log("SCENARIO_EDITOR", $"ShellOpen '{path}' failed: {err}", LogLevel.Error);
SetStatus($"{ToolUi.Tr("tools/scenario_editor/open_file_error")}: {err}", ToolStatusKind.Error);
}
}
private void OnPlayPressed()
@@ -285,7 +262,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
if (metadata.Scene == "-")
{
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/no_scene_error");
SetStatus(ToolUi.Tr("tools/scenario_editor/no_scene_error"), ToolStatusKind.Error);
return;
}
@@ -299,7 +276,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
else
{
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip");
GameLogger.Log("SCENARIO_EDITOR",
$"Test play: '{modId}/{relativePath}' is read-only, playing from the editor buffer",
LogLevel.Warning);
@@ -307,14 +283,10 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
catch (Exception ex)
{
_statusLabel.Text = string.Format(
TranslationServer.Translate("tools/scenario_editor/save_error"), ex.Message);
SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"), ex.Message), ToolStatusKind.Error);
return;
}
_editLayout.Visible = false;
_playLayout.Visible = true;
SceneManager.IsTestPlayActive = true;
SceneManager.TestScenarioText = text;
@@ -348,8 +320,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private void ReturnFromPlay()
{
_playActive = false;
_playLayout.Visible = false;
_editLayout.Visible = true;
Visible = true;
if (_stopOverlay != null)
@@ -360,22 +330,21 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
var sceneService = GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");
sceneService?.SwitchToMenu();
SetStatus(ToolUi.Tr("tools/scenario_editor/play_stopped"));
}
/// <summary>Stop button shown over the running game while the scenario is play-tested.</summary>
private CanvasLayer CreateStopOverlay()
{
var layer = new CanvasLayer { Layer = 128 };
var btn = new Button
{
Text = TranslationServer.Translate("tools/scenario_editor/stop"),
CustomMinimumSize = new Vector2(120, 40)
};
btn.Pressed += OnStopPressed;
var btn = ToolUi.Button("tools/scenario_editor/stop", OnStopPressed, ButtonKind.Danger);
btn.Theme = ToolTheme.Get();
btn.CustomMinimumSize = new Vector2(120, 36);
var margin = new MarginContainer();
margin.AddThemeConstantOverride("margin_top", 8);
margin.AddThemeConstantOverride("margin_left", 8);
margin.AddThemeConstantOverride("margin_top", UiTokens.Space3);
margin.AddThemeConstantOverride("margin_left", UiTokens.Space3);
margin.AddChild(btn);
layer.AddChild(margin);
+100 -51
View File
@@ -6,82 +6,138 @@ using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Left-hand tree of every mod's scenario files, grouped by mod and subdirectory; raises <see cref="FileSelected"/> on picks.</summary>
/// <summary>
/// Left-hand tree of every mod's scenario files, grouped by mod and
/// subdirectory, with a search field that keeps only matching files (and
/// expands their groups). Raises <see cref="FileSelected"/> with the
/// scenario key on picks.
/// <para/>
/// Built from <c>scenario_file_tree_panel.tscn</c>: the tscn builder creates
/// the script node through its constructor before the child nodes exist,
/// so children are looked up lazily (<see cref="Bind"/>) — not in the
/// constructor, which is what used to crash the whole editor on open.
/// </summary>
public partial class ScenarioFileTreePanel : VBoxContainer
{
private Tree _fileTree;
private readonly Dictionary<string, string> _fileItemToKey = new();
private readonly Dictionary<string, TreeItem> _keyToTreeItem = new();
private LineEdit _search;
private List<ModScenarioTree> _mods = new();
private readonly Dictionary<TreeItem, string> _itemToKey = new();
private readonly Dictionary<string, TreeItem> _keyToItem = new();
private bool _isProgrammaticSelection;
private string _selectedKey;
public event Action<string> FileSelected;
public ScenarioFileTreePanel()
/// <summary>Binds child nodes and fills the tree once the panel enters the tree.</summary>
public override void _Ready()
{
Reload();
}
/// <summary>Re-scans the mods' scenario files and rebuilds the tree.</summary>
public void Reload()
{
Bind();
_mods = ScenarioFileService.ScanScenarioFiles();
Populate();
}
private void Bind()
{
if (_fileTree != null)
return;
_fileTree = GetNode<Tree>("FileTree");
_fileTree.CellSelected += OnCellSelected;
Populate();
_search = GetNode<LineEdit>("Search");
_search.PlaceholderText = TranslationServer.Translate("tools/scenario_editor/search_files");
_search.TextChanged += _ => Populate();
}
private void Populate()
{
_fileTree.Clear();
_fileItemToKey.Clear();
_keyToTreeItem.Clear();
_itemToKey.Clear();
_keyToItem.Clear();
var filter = _search.Text.Trim();
var root = _fileTree.CreateItem();
var mods = ScenarioFileService.ScanScenarioFiles();
foreach (var mod in mods)
foreach (var mod in _mods)
{
var modItem = _fileTree.CreateItem(root);
modItem.SetText(0, $"[{mod.ModId}]");
modItem.SetSelectable(0, false);
modItem.SetCustomColor(0, UiTokens.Info);
TreeItem modItem = null;
foreach (var group in mod.Groups)
{
TreeItem parentItem;
if (string.IsNullOrEmpty(group.DirectoryPath))
{
parentItem = modItem;
}
else
{
parentItem = _fileTree.CreateItem(modItem);
parentItem.SetText(0, group.DirectoryPath + "/");
parentItem.SetSelectable(0, false);
}
TreeItem groupItem = null;
foreach (var file in group.Files)
{
var fileItem = _fileTree.CreateItem(parentItem);
if (filter.Length > 0 && file.RelativePath.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
continue;
if (modItem == null)
{
modItem = _fileTree.CreateItem(root);
modItem.SetText(0, mod.ModId);
modItem.SetSelectable(0, false);
modItem.SetCustomColor(0, UiTokens.AccentBright);
}
if (groupItem == null)
{
if (string.IsNullOrEmpty(group.DirectoryPath))
{
groupItem = modItem;
}
else
{
groupItem = _fileTree.CreateItem(modItem);
groupItem.SetText(0, group.DirectoryPath + "/");
groupItem.SetSelectable(0, false);
groupItem.SetCustomColor(0, UiTokens.Muted);
groupItem.Collapsed = filter.Length == 0;
}
}
var fileItem = _fileTree.CreateItem(groupItem);
fileItem.SetText(0, file.Name);
fileItem.SetMetadata(0, Variant.From("scenario"));
_fileItemToKey[GetItemKey(fileItem)] = file.Key;
_keyToTreeItem[file.Key] = fileItem;
fileItem.SetTooltipText(0, ScenarioFileService.GetDisplayPath(file.ModId, file.RelativePath));
_itemToKey[fileItem] = file.Key;
_keyToItem[file.Key] = fileItem;
}
}
}
if (_selectedKey != null && _keyToItem.TryGetValue(_selectedKey, out var selected))
SelectSilently(selected);
}
private static string GetItemKey(TreeItem item)
{
return item.GetInstanceId().ToString();
}
/// <summary>Programmatically selects the tree row for a scenario key (no selection event).</summary>
/// <summary>Selects the tree row of a scenario key and raises <see cref="FileSelected"/>.</summary>
public void SelectFile(string key)
{
if (!_keyToTreeItem.TryGetValue(key, out var item))
return;
Bind();
_selectedKey = key;
if (!_keyToItem.TryGetValue(key, out var item))
{
_search.Text = string.Empty;
Populate();
if (!_keyToItem.TryGetValue(key, out item))
return;
}
SelectSilently(item);
FileSelected?.Invoke(key);
}
private void SelectSilently(TreeItem item)
{
for (var parent = item.GetParent(); parent != null; parent = parent.GetParent())
parent.Collapsed = false;
_isProgrammaticSelection = true;
item.Select(0);
_fileTree.ScrollToItem(item);
_isProgrammaticSelection = false;
FileSelected?.Invoke(key);
}
private void OnCellSelected()
@@ -90,18 +146,11 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
return;
var item = _fileTree.GetSelected();
if (item == null)
if (item == null || !_itemToKey.TryGetValue(item, out var key))
return;
var meta = item.GetMetadata(0);
if (meta.VariantType != Variant.Type.String || meta.AsString() != "scenario")
return;
var itemKey = GetItemKey(item);
if (!_fileItemToKey.TryGetValue(itemKey, out var fileKey))
return;
FileSelected?.Invoke(fileKey);
_selectedKey = key;
FileSelected?.Invoke(key);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// Layered left-to-right layout of the scene graph: a scene's column is
/// its distance (in scene switches) from the start scenes — the
/// <c>start_scene</c> node, or every scene nothing switches to when there
/// is none — so the story reads from left to right. Scenes no start
/// reaches are stacked in one extra column on the right. Inside a column
/// scenes are sorted by name, which keeps the picture stable between
/// rebuilds.
/// </summary>
public static class ScenarioGraphLayout
{
private const float ColumnWidth = 300f;
private const float RowHeight = 130f;
private const float Margin = 40f;
/// <summary>Canvas position per lower-cased scene name.</summary>
public static Dictionary<string, Vector2> Compute(SceneGraphData data)
{
var names = data.Nodes.Select(n => n.SceneName.ToLowerInvariant()).Distinct().ToList();
var outgoing = names.ToDictionary(n => n, _ => new List<string>());
var incoming = new HashSet<string>();
foreach (var edge in data.Edges)
{
var from = edge.FromScene.ToLowerInvariant();
var to = edge.ToScene.ToLowerInvariant();
if (!outgoing.ContainsKey(from) || !outgoing.ContainsKey(to) || from == to)
continue;
outgoing[from].Add(to);
incoming.Add(to);
}
var starts = data.Nodes.Where(n => n.IsStartScene).Select(n => n.SceneName.ToLowerInvariant()).ToList();
if (starts.Count == 0)
starts = names.Where(n => !incoming.Contains(n)).ToList();
var depth = new Dictionary<string, int>();
var queue = new Queue<string>();
foreach (var start in starts)
{
depth[start] = 0;
queue.Enqueue(start);
}
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var next in outgoing[current])
{
if (depth.ContainsKey(next))
continue;
depth[next] = depth[current] + 1;
queue.Enqueue(next);
}
}
var unreachableColumn = depth.Count > 0 ? depth.Values.Max() + 1 : 0;
var result = new Dictionary<string, Vector2>();
foreach (var column in names.GroupBy(n => depth.TryGetValue(n, out var d) ? d : unreachableColumn).OrderBy(g => g.Key))
{
var row = 0;
foreach (var name in column.OrderBy(n => n, StringComparer.Ordinal))
result[name] = new Vector2(Margin + column.Key * ColumnWidth, Margin + row++ * RowHeight);
}
return result;
}
}
}
+23 -29
View File
@@ -41,23 +41,22 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_graph.NodeDeselected += OnNodeDeselected;
AddChild(_graph);
var panel = new VBoxContainer { CustomMinimumSize = new Vector2(250, 0) };
var panel = new VBoxContainer { CustomMinimumSize = new Vector2(260, 0) };
panel.SizeFlagsVertical = SizeFlags.ExpandFill;
AddChild(panel);
panel.AddChild(new Label
{
Text = TranslationServer.Translate("tools/scenario_editor/graph_select_node"),
AutowrapMode = TextServer.AutowrapMode.WordSmart
});
var hint = UiKit.Hint(TranslationServer.Translate("tools/scenario_editor/graph_select_node"));
hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
panel.AddChild(hint);
_infoLabel = new RichTextLabel
{
BbcodeEnabled = true,
FitContent = true,
ScrollFollowing = true,
SizeFlagsVertical = SizeFlags.ExpandFill
};
_infoLabel.AddThemeFontSizeOverride("normal_font_size", UiTokens.FontBody);
_infoLabel.AddThemeFontSizeOverride("bold_font_size", UiTokens.FontBodyLarge);
_infoLabel.AddThemeColorOverride("default_color", UiTokens.Body);
_infoLabel.MetaClicked += OnLinkClicked;
panel.AddChild(_infoLabel);
}
@@ -71,13 +70,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_nodes = new Dictionary<string, GraphNode>();
_lookup = new Dictionary<string, SceneGraphNode>();
int x = 40, y = 40;
foreach (var pos in savedPositions.Values)
{
int bottom = (int)pos.Y + 160 + 20;
if (bottom > y)
y = bottom;
}
var layout = ScenarioGraphLayout.Compute(data);
foreach (var nodeData in data.Nodes)
{
@@ -90,7 +83,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
var position = savedPositions.TryGetValue(key, out var saved)
? saved
: new Vector2(x, y);
: layout[key];
var gn = new GraphNode
{
@@ -99,12 +92,15 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
PositionOffset = position
};
gn.AddChild(new Label
var nodeLabel = new Label
{
Text = $"Mod: {nodeData.ModId}\nScenarios: {nodeData.ScenarioCount}" +
(bgCount > 0 ? $"\nBg: {firstBg}" +
Text = string.Format(Tr("tools/scenario_editor/graph_node"), nodeData.ModId, nodeData.ScenarioCount) +
(bgCount > 0 ? "\n" + string.Format(Tr("tools/scenario_editor/graph_node_bg"), firstBg) +
(bgCount > 1 ? $" +{bgCount - 1}" : "") : "")
});
};
nodeLabel.AddThemeColorOverride("font_color", UiTokens.Body);
nodeLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
gn.AddChild(nodeLabel);
var color = UiTokens.White;
if (nodeData.IsStartScene)
@@ -123,11 +119,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_nodes[key] = gn;
_lookup[key] = nodeData;
if (!savedPositions.ContainsKey(key))
{
x += 280;
if (x > 1000) { x = 40; y += 160; }
}
}
foreach (var edge in data.Edges)
@@ -138,6 +129,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
continue;
_graph.ConnectNode(_nodes[srcK].Name, 0, _nodes[dstK].Name, 0);
}
}
private Dictionary<string, Vector2> SaveNodePositions()
@@ -181,12 +173,12 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
return;
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Scene: [b]{nodeData.SceneName}[/b]");
sb.AppendLine($"Mod: {nodeData.ModId}");
sb.AppendLine($"[b]{nodeData.SceneName}[/b]");
sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_detail_mod"), nodeData.ModId));
if (nodeData.Backgrounds.Count > 0)
sb.AppendLine($"Backgrounds: {string.Join(", ", nodeData.Backgrounds)}");
sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_node_bg"), string.Join(", ", nodeData.Backgrounds)));
sb.AppendLine();
sb.AppendLine($"Scenarios ({nodeData.Scenarios.Count}):");
sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_detail_scenarios"), nodeData.Scenarios.Count));
foreach (var sc in nodeData.Scenarios.OrderBy(s => s.Priority))
{
@@ -202,6 +194,8 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_infoLabel.Text = "";
}
private static string Tr(string key) => TranslationServer.Translate(key);
private void OnLinkClicked(Variant meta)
{
var path = Uri.UnescapeDataString(meta.AsString());
+49 -13
View File
@@ -1,5 +1,7 @@
using System.Linq;
using Cthangover.Tools.Services;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Tool;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
@@ -14,26 +16,59 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private Label _infoLocaleKeys;
private Label _infoQuestRefs;
public ScenarioInfoPanel()
/// <summary>Binds the rows once the panel enters the tree (see <see cref="Bind"/>).</summary>
public override void _Ready()
{
GetNode<Label>("Scroll/VBox/HintScene").Text = TranslationServer.Translate("tools/scenario_editor/info_scene") + ":";
GetNode<Label>("Scroll/VBox/HintPriority").Text = TranslationServer.Translate("tools/scenario_editor/info_priority") + ":";
GetNode<Label>("Scroll/VBox/HintCondition").Text = TranslationServer.Translate("tools/scenario_editor/info_condition") + ":";
GetNode<Label>("Scroll/VBox/HintSwitchTargets").Text = TranslationServer.Translate("tools/scenario_editor/info_switch_targets") + ":";
GetNode<Label>("Scroll/VBox/HintLocaleKeys").Text = TranslationServer.Translate("tools/scenario_editor/info_locale_keys") + ":";
GetNode<Label>("Scroll/VBox/HintQuestRefs").Text = TranslationServer.Translate("tools/scenario_editor/info_quest_refs") + ":";
Bind();
}
_infoScene = GetNode<Label>("Scroll/VBox/InfoScene");
_infoPriority = GetNode<Label>("Scroll/VBox/InfoPriority");
_infoCondition = GetNode<Label>("Scroll/VBox/InfoCondition");
_infoSwitchTargets = GetNode<Label>("Scroll/VBox/InfoSwitchTargets");
_infoLocaleKeys = GetNode<Label>("Scroll/VBox/InfoLocaleKeys");
_infoQuestRefs = GetNode<Label>("Scroll/VBox/InfoQuestRefs");
/// <summary>
/// Looks the rows up lazily: the tscn builder runs the constructor before
/// the child nodes exist, so lookups there would fail.
/// </summary>
private void Bind()
{
if (_infoScene != null)
return;
SetHint("HintScene", "tools/scenario_editor/info_scene");
SetHint("HintPriority", "tools/scenario_editor/info_priority");
SetHint("HintCondition", "tools/scenario_editor/info_condition");
SetHint("HintSwitchTargets", "tools/scenario_editor/info_switch_targets");
SetHint("HintLocaleKeys", "tools/scenario_editor/info_locale_keys");
SetHint("HintQuestRefs", "tools/scenario_editor/info_quest_refs");
_infoScene = Value("InfoScene");
_infoPriority = Value("InfoPriority");
_infoCondition = Value("InfoCondition");
_infoSwitchTargets = Value("InfoSwitchTargets");
_infoLocaleKeys = Value("InfoLocaleKeys");
_infoQuestRefs = Value("InfoQuestRefs");
}
private void SetHint(string node, string key)
{
var label = GetNode<Label>("Scroll/VBox/" + node);
label.Text = TranslationServer.Translate(key).ToString().ToUpperInvariant();
label.AddThemeColorOverride("font_color", UiTokens.Muted);
label.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
}
private Label Value(string node)
{
var label = GetNode<Label>("Scroll/VBox/" + node);
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
label.SizeFlagsHorizontal = SizeFlags.ExpandFill;
label.CustomMinimumSize = new Vector2(120, 0);
label.AddThemeFontOverride("font", ToolWindow.GetMonospaceFont());
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
return label;
}
/// <summary>Parses the given scenario text and refreshes every metadata row.</summary>
public void Update(string text)
{
Bind();
if (string.IsNullOrEmpty(text))
{
Clear();
@@ -54,6 +89,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
/// <summary>Resets every metadata row to the "-" placeholder.</summary>
public void Clear()
{
Bind();
_infoScene.Text = "-";
_infoPriority.Text = "-";
_infoCondition.Text = "-";
+10 -9
View File
@@ -9,10 +9,11 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
private HBoxContainer _thumbContainer;
public ScenarioThumbnailPanel()
{
_thumbContainer = GetNode<HBoxContainer>("ThumbContainer");
}
/// <summary>
/// Container lookup is lazy: the tscn builder runs the constructor
/// before the child nodes exist.
/// </summary>
private HBoxContainer Thumbs => _thumbContainer ??= GetNode<HBoxContainer>("ThumbContainer");
/// <summary>Loads the background thumbnails referenced by the given scenario text (missing ones render as placeholders).</summary>
public void Load(string text)
@@ -26,7 +27,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
if (refs.BackgroundRefs.Count == 0)
{
_thumbContainer.AddChild(new Label
Thumbs.AddChild(new Label
{
Text = TranslationServer.Translate("tools/scenario_editor/no_backgrounds")
});
@@ -39,7 +40,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
if (tex != null)
{
_thumbContainer.AddChild(new TextureRect
Thumbs.AddChild(new TextureRect
{
Texture = tex,
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
@@ -50,10 +51,10 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
else
{
_thumbContainer.AddChild(new Label
Thumbs.AddChild(new Label
{
Text = $"[? {id}]",
TooltipText = $"Not found: {id}"
TooltipText = string.Format(TranslationServer.Translate("tools/scenario_editor/bg_missing"), id)
});
}
}
@@ -62,7 +63,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
/// <summary>Removes every thumbnail from the row.</summary>
public new void Clear()
{
foreach (var c in _thumbContainer.GetChildren())
foreach (var c in Thumbs.GetChildren())
c.QueueFree();
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// State of one open scenario file tab: identity, the current text buffer
/// (including unsaved edits while the tab is in the background) and the
/// dirty flag.
/// </summary>
public sealed class TabData
{
public string Key;
public string ModId;
public string RelativePath;
public string FileName;
public string Buffer;
public bool IsDirty;
}
}