Tools: one toolbar button for all tools, styled tool windows
- ToolBox: instead of a text button per tool (which did not fit the 60x60 icon row and overlapped the icons) the HUD gets one "tools" icon button with a popup listing every visible tool, sorted by localized name. - ToolWindow: chrome that follows the UI style guide (title on the left, close on the right, drag-to-move, double-click/maximize, edge resize), a status bar with buffered status/hint text, dirty-state confirmation and keyboard shortcuts (Ctrl+S in _Input, other keys only when no focused control consumed them). - ToolTheme: denser copy of the game theme for editor toolbars; ToolStatusKind: status bar message kinds. - New tools icon and the tool window texts (unsaved changes, maximize, close, menu title) in en/ru.
This commit is contained in:
1 parent
6884cd5374
commit
b2a0f1c10a
7 files changed
+422
-143
No files matched your search
@@ -259,3 +259,10 @@ ui/journal/entry_of = Entry {0} of {1}
|
||||
ui/journal/finished = Completed
|
||||
ui/journal/seals = Seals
|
||||
ui/journal/no_entries = The pages of this entry are still empty. Ink will appear as the story moves on.
|
||||
|
||||
tools/unsaved_title = Unsaved changes
|
||||
tools/unsaved_text = You have unsaved changes. Close without saving?
|
||||
tools/unsaved_discard_text = There are unsaved changes. Discard them?
|
||||
tools/window_maximize = Maximize / restore (double-click the title)
|
||||
tools/window_close = Close
|
||||
tools/menu_title = Tools
|
||||
@@ -259,3 +259,10 @@ ui/journal/seals = Печати
|
||||
ui/journal/no_entries = Страницы этой записи ещё пусты. Чернила появятся, когда история сдвинется с места.
|
||||
mod_interface_name = Интерфейс
|
||||
mod_interface_desc = Подсистема UI движка: набор виджетов (Widget/ModWidget, списки), ViewBox фона, тулбар ToolBox, GameMenu/SaveIcon, панели уведомлений, дизайн-система UiKit и DeviceInput. Ядро работает с модом через контракты IWidget/IToolbarHost и мосты.
|
||||
|
||||
tools/unsaved_title = Есть изменения
|
||||
tools/unsaved_text = У вас есть несохранённые изменения. Закрыть без сохранения?
|
||||
tools/unsaved_discard_text = Есть несохранённые изменения. Отбросить их?
|
||||
tools/window_maximize = Развернуть / восстановить (двойной клик по заголовку)
|
||||
tools/window_close = Закрыть
|
||||
tools/menu_title = Инструменты
|
||||
+28
-11
@@ -89,29 +89,46 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
return button;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds one "tools" toolbar button whose popup lists every visible
|
||||
/// <see cref="IToolBoxButton"/>. Tools used to get a text button each,
|
||||
/// which did not fit the 60×60 icon row and overlapped its icons; a
|
||||
/// single icon keeps the HUD row uniform no matter how many tools are
|
||||
/// installed. Nothing is added when no tool button is visible.
|
||||
/// </summary>
|
||||
private void AddToolButtons(HBoxContainer container)
|
||||
{
|
||||
if (container == null)
|
||||
return;
|
||||
|
||||
foreach (var buttonDef in ToolBoxButtonFactory.Instance.GetVisible())
|
||||
{
|
||||
var toolId = buttonDef.ToolId;
|
||||
var title = TranslationServer.Translate(buttonDef.LocaleKey);
|
||||
var visible = new List<IToolBoxButton>(ToolBoxButtonFactory.Instance.GetVisible());
|
||||
if (visible.Count == 0)
|
||||
return;
|
||||
|
||||
Texture2D icon = null;
|
||||
if (!string.IsNullOrEmpty(buttonDef.IconPath) && ResourceLoader.Exists(buttonDef.IconPath))
|
||||
icon = ResourceLoader.Load<Texture2D>(buttonDef.IconPath);
|
||||
visible.Sort((a, b) => string.Compare(
|
||||
TranslationServer.Translate(a.LocaleKey), TranslationServer.Translate(b.LocaleKey),
|
||||
System.StringComparison.CurrentCulture));
|
||||
|
||||
var btn = CreateToolbarButton(title, icon);
|
||||
var btn = CreateToolbarButton(TranslationServer.Translate("tools/menu_title"), UIIconFactory.Instance.Get("tools"));
|
||||
var menu = new PopupMenu { Theme = ToolTheme.Get() };
|
||||
foreach (var buttonDef in visible)
|
||||
menu.AddItem(TranslationServer.Translate(buttonDef.LocaleKey));
|
||||
menu.IndexPressed += index => OpenTool(visible[(int)index].ToolId);
|
||||
btn.AddChild(menu);
|
||||
btn.Pressed += () =>
|
||||
{
|
||||
var rect = btn.GetGlobalRect();
|
||||
menu.Position = new Vector2I((int)rect.Position.X, (int)rect.End.Y + UiTokens.Space1);
|
||||
menu.Popup();
|
||||
};
|
||||
container.AddChild(btn);
|
||||
}
|
||||
|
||||
private static void OpenTool(string toolId)
|
||||
{
|
||||
var tool = ToolFactory.Instance.Get(toolId);
|
||||
if (tool != null)
|
||||
ToolWindow.ShowWindow(tool.CreateWindow());
|
||||
};
|
||||
container.AddChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Cthangover.Mods.Interface.Tool
|
||||
{
|
||||
/// <summary>
|
||||
/// Tone of a message in the <see cref="ToolWindow"/> status bar, mapped to the
|
||||
/// style guide's inline messages: neutral info, ok (<c>.ct-message--ok</c>)
|
||||
/// and error (<c>.ct-message--err</c>).
|
||||
/// </summary>
|
||||
public enum ToolStatusKind
|
||||
{
|
||||
Info,
|
||||
Ok,
|
||||
Error,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Interface.Tool
|
||||
{
|
||||
/// <summary>
|
||||
/// Dense variant of <see cref="CthangoverTheme"/> for developer tool windows.
|
||||
/// The game theme is tuned for player panels (buttons padded 18×11, inputs
|
||||
/// 12×9), which makes an editor toolbar of ten controls overflow even a
|
||||
/// full-HD window. This theme keeps every colour, radius and type variation
|
||||
/// of the shared theme — the style guide still holds — and only tightens
|
||||
/// the content margins of the boxes, so tools fit more controls without a
|
||||
/// second palette. Built once by copying the shared theme and replacing its
|
||||
/// flat boxes with tightened duplicates; the shared theme is never mutated.
|
||||
/// </summary>
|
||||
public static class ToolTheme
|
||||
{
|
||||
private const int MaxVerticalMargin = 5;
|
||||
private const int MaxHorizontalMargin = 10;
|
||||
|
||||
private static Theme instance;
|
||||
|
||||
/// <summary>Shared dense theme instance, built lazily from the game theme.</summary>
|
||||
public static Theme Get() => instance ??= Build();
|
||||
|
||||
private static Theme Build()
|
||||
{
|
||||
var theme = (Theme)CthangoverTheme.Get().Duplicate();
|
||||
theme.DefaultFontSize = UiTokens.FontBody;
|
||||
|
||||
foreach (var type in theme.GetTypeList())
|
||||
{
|
||||
foreach (var name in theme.GetStyleboxList(type))
|
||||
{
|
||||
if (theme.GetStylebox(name, type) is not StyleBoxFlat flat)
|
||||
continue;
|
||||
|
||||
var dense = (StyleBoxFlat)flat.Duplicate();
|
||||
dense.ContentMarginTop = Mathf.Min(dense.ContentMarginTop, MaxVerticalMargin);
|
||||
dense.ContentMarginBottom = Mathf.Min(dense.ContentMarginBottom, MaxVerticalMargin);
|
||||
dense.ContentMarginLeft = Mathf.Min(dense.ContentMarginLeft, MaxHorizontalMargin);
|
||||
dense.ContentMarginRight = Mathf.Min(dense.ContentMarginRight, MaxHorizontalMargin);
|
||||
theme.SetStylebox(name, type, dense);
|
||||
}
|
||||
|
||||
if (theme.HasFontSize("font_size", type) && theme.GetFontSize("font_size", type) > UiTokens.FontBody)
|
||||
theme.SetFontSize("font_size", type, UiTokens.FontBody);
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
}
|
||||
}
|
||||
+313
-132
@@ -8,23 +8,34 @@ using Cthangover.Core.UI.Tool;
|
||||
namespace Cthangover.Mods.Interface.Tool
|
||||
{
|
||||
/// <summary>
|
||||
/// Base for tool popup windows with dirty-state tracking and custom borderless
|
||||
/// chrome (title bar with minimize/maximize/close, drag-to-move, edge resize).
|
||||
/// Subclasses build their UI in the constructor exactly as before — the chrome
|
||||
/// is applied transparently by <see cref="SetupChrome"/> after construction.
|
||||
/// Base for developer tool windows: borderless chrome that follows the UI
|
||||
/// style guide (title left in the header, ✕ on the right, drag-to-move,
|
||||
/// double-click/□ to maximize, edge resize), a status bar, dirty-state
|
||||
/// tracking and keyboard shortcuts.
|
||||
/// <para/>
|
||||
/// Subclasses build their UI in the constructor and add it as direct
|
||||
/// children; <see cref="SetupChrome"/> (run by <see cref="Open{T}"/> and
|
||||
/// <see cref="ShowWindow"/>) later moves those children into the content
|
||||
/// area between the header and the status bar. Status and hint text may be
|
||||
/// set before the chrome exists — it is buffered and shown once built.
|
||||
/// <para/>
|
||||
/// Tool windows use <see cref="ToolTheme"/>, a denser copy of the game
|
||||
/// theme: same tokens, smaller paddings, so editor toolbars fit.
|
||||
/// <para/>
|
||||
/// Shortcuts: Ctrl+S calls <see cref="OnSaveShortcut"/> even while a text
|
||||
/// field has focus (checked in <c>_Input</c>, before the GUI); every other
|
||||
/// key reaches <see cref="HandleKey"/> only if no focused control consumed
|
||||
/// it (<c>_UnhandledInput</c>), so Delete in a text field never deletes a
|
||||
/// selected object.
|
||||
/// </summary>
|
||||
public abstract partial class ToolWindow : Window
|
||||
{
|
||||
// ── existing fields ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Whether the window has unsaved changes. When <c>true</c>, the title bar shows a "*" prefix
|
||||
/// and <see cref="HandleCloseRequested"/> prompts for confirmation before closing.
|
||||
/// and closing asks for confirmation first.
|
||||
/// </summary>
|
||||
protected bool _dirty;
|
||||
private string _baseTitle;
|
||||
|
||||
// ── chrome fields ───────────────────────────────────────────
|
||||
private readonly string _baseTitle;
|
||||
|
||||
private bool _chromeBuilt;
|
||||
|
||||
@@ -35,13 +46,16 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
private Label _titleLabel;
|
||||
private Button _maximizeBtn;
|
||||
private HBoxContainer _titleBar;
|
||||
private Label _statusLabel;
|
||||
private Label _hintLabel;
|
||||
private string _pendingStatus = string.Empty;
|
||||
private ToolStatusKind _pendingStatusKind;
|
||||
private string _pendingHint = string.Empty;
|
||||
|
||||
// drag
|
||||
private bool _draggingTitle;
|
||||
private Vector2 _titleDragMouseStart;
|
||||
private Vector2I _titleDragWindowStart;
|
||||
|
||||
// resize
|
||||
private enum ResizeEdge { None, Left, Right, Top, Bottom, TopLeft, TopRight, BottomLeft, BottomRight }
|
||||
private bool _resizing;
|
||||
private ResizeEdge _resizeEdge;
|
||||
@@ -51,9 +65,18 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
|
||||
private const int EDGE_ZONE = 6;
|
||||
private const int CORNER_ZONE = 14;
|
||||
private const int TITLE_BAR_HEIGHT = 32;
|
||||
private const int TITLE_BAR_HEIGHT = 40;
|
||||
private const int TITLE_BUTTON_SIZE = 38;
|
||||
private const float DefaultSizeRatio = 0.9f;
|
||||
|
||||
// ── viewport sizing ─────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Height of the in-game HUD icon row. HUD canvas layers draw above
|
||||
/// embedded windows, so a window placed under it would have its title
|
||||
/// bar covered; windows open below this line.
|
||||
/// </summary>
|
||||
private const int HudTopInset = 84;
|
||||
|
||||
private static Font _monospaceFont;
|
||||
|
||||
private static Vector2 GetGameViewportSize()
|
||||
{
|
||||
@@ -74,11 +97,7 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
public static T Open<T>() where T : ToolWindow, new()
|
||||
{
|
||||
var window = new T();
|
||||
window.SetupChrome();
|
||||
var tree = Godot.Engine.GetMainLoop() as SceneTree;
|
||||
tree?.Root.AddChild(window);
|
||||
window.PopupCentered();
|
||||
GameLogger.Log("TOOL_WINDOW", $"Open<{typeof(T).Name}> gameViewport=({GetGameViewportSize().X:F0},{GetGameViewportSize().Y:F0}) physWindow=({DisplayServer.WindowGetSize().X},{DisplayServer.WindowGetSize().Y}) windowSize=({window.Size.X},{window.Size.Y})");
|
||||
ShowWindow(window);
|
||||
return window;
|
||||
}
|
||||
|
||||
@@ -90,11 +109,14 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
{
|
||||
if (window is ToolWindow tw)
|
||||
tw.SetupChrome();
|
||||
else
|
||||
CthangoverTheme.Apply(window);
|
||||
var tree = Godot.Engine.GetMainLoop() as SceneTree;
|
||||
tree?.Root.AddChild(window);
|
||||
window.PopupCentered();
|
||||
GameLogger.Log("TOOL_WINDOW", $"ShowWindow gameViewport=({GetGameViewportSize().X:F0},{GetGameViewportSize().Y:F0}) physWindow=({DisplayServer.WindowGetSize().X},{DisplayServer.WindowGetSize().Y}) windowSize=({window.Size.X},{window.Size.Y})");
|
||||
if (window is ToolWindow && window.Position.Y < HudTopInset)
|
||||
window.Position = new Vector2I(window.Position.X, HudTopInset);
|
||||
GameLogger.Log("TOOL_WINDOW", $"ShowWindow '{window.GetType().Name}' size=({window.Size.X},{window.Size.Y})");
|
||||
return window;
|
||||
}
|
||||
|
||||
@@ -105,7 +127,7 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
_baseTitle = TranslationServer.Translate(titleKey);
|
||||
Title = _baseTitle;
|
||||
Unresizable = false;
|
||||
CloseRequested += HandleCloseRequested;
|
||||
CloseRequested += RequestClose;
|
||||
}
|
||||
|
||||
/// <summary>Runs the <see cref="Cleanup"/> teardown hook when the window leaves the tree.</summary>
|
||||
@@ -114,18 +136,46 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
Cleanup();
|
||||
}
|
||||
|
||||
/// <summary>Ctrl+S → <see cref="OnSaveShortcut"/>, checked before the GUI so it works inside text fields.</summary>
|
||||
public override void _Input(InputEvent @event)
|
||||
{
|
||||
if (@event is not InputEventKey key || !key.Pressed || key.Echo)
|
||||
return;
|
||||
|
||||
if (key.CtrlPressed && key.Keycode == Key.S)
|
||||
{
|
||||
OnSaveShortcut();
|
||||
SetInputAsHandled();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forwards keys no control consumed to <see cref="HandleKey"/>.</summary>
|
||||
public override void _UnhandledInput(InputEvent @event)
|
||||
{
|
||||
if (@event is InputEventKey key && key.Pressed && HandleKey(key))
|
||||
SetInputAsHandled();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to release resources when the window is closed. Called from <c>_ExitTree</c>.
|
||||
/// </summary>
|
||||
protected virtual void Cleanup() { }
|
||||
|
||||
/// <summary>Ctrl+S handler; tools with a save action override it.</summary>
|
||||
protected virtual void OnSaveShortcut() { }
|
||||
|
||||
/// <summary>
|
||||
/// Tool-specific shortcut for a key no focused control consumed.
|
||||
/// Return <c>true</c> when handled.
|
||||
/// </summary>
|
||||
protected virtual bool HandleKey(InputEventKey key) => false;
|
||||
|
||||
// ── chrome setup ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Wraps all currently-added children inside a custom chrome layout
|
||||
/// (title bar + border + resize zones). Called automatically by
|
||||
/// <see cref="Open{T}"/> and <see cref="ShowWindow"/> after the
|
||||
/// subclass constructor has built its UI. Safe to call only once.
|
||||
/// Wraps all currently-added children inside the chrome layout (title bar,
|
||||
/// content area, status bar, resize zones). Called automatically by
|
||||
/// <see cref="Open{T}"/> and <see cref="ShowWindow"/>; safe to call twice.
|
||||
/// </summary>
|
||||
internal void SetupChrome()
|
||||
{
|
||||
@@ -133,18 +183,19 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
return;
|
||||
_chromeBuilt = true;
|
||||
|
||||
CthangoverTheme.Apply(this);
|
||||
|
||||
var initialSize = Size;
|
||||
Theme = ToolTheme.Get();
|
||||
|
||||
// Read the requested size first: assigning MinSize clamps Size at once.
|
||||
var requested = Size;
|
||||
var viewport = GetGameViewportSize();
|
||||
Borderless = true;
|
||||
MinSize = new Vector2I(800, 400);
|
||||
MaxSize = (Vector2I)GetGameViewportSize();
|
||||
MaxSize = (Vector2I)viewport;
|
||||
|
||||
if (initialSize.X < MinSize.X || initialSize.Y < MinSize.Y)
|
||||
Size = MaxSize;
|
||||
|
||||
GameLogger.Log("TOOL_WINDOW", $"SetupChrome gameViewport=({GetGameViewportSize().X:F0},{GetGameViewportSize().Y:F0}) physWindow=({DisplayServer.WindowGetSize().X},{DisplayServer.WindowGetSize().Y}) maxSize=({MaxSize.X},{MaxSize.Y})");
|
||||
if (requested.X < MinSize.X || requested.Y < MinSize.Y)
|
||||
requested = (Vector2I)(viewport * DefaultSizeRatio);
|
||||
var maxHeight = MaxSize.Y - HudTopInset - UiTokens.Space3;
|
||||
Size = new Vector2I(Mathf.Min(requested.X, MaxSize.X), Mathf.Min(requested.Y, maxHeight));
|
||||
|
||||
var existingChildren = new List<Node>();
|
||||
foreach (Node child in GetChildren())
|
||||
@@ -155,12 +206,21 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
var rootPanel = BuildRootPanel();
|
||||
|
||||
var rootVBox = new VBoxContainer();
|
||||
rootVBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
rootVBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||
rootVBox.AddThemeConstantOverride("separation", 0);
|
||||
rootPanel.AddChild(rootVBox);
|
||||
|
||||
var titleBar = BuildTitleBar();
|
||||
rootVBox.AddChild(titleBar);
|
||||
rootVBox.AddChild(BuildTitleBar());
|
||||
|
||||
var contentMargin = new MarginContainer
|
||||
{
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
||||
};
|
||||
contentMargin.AddThemeConstantOverride("margin_left", UiTokens.Space3);
|
||||
contentMargin.AddThemeConstantOverride("margin_right", UiTokens.Space3);
|
||||
contentMargin.AddThemeConstantOverride("margin_top", UiTokens.Space3);
|
||||
contentMargin.AddThemeConstantOverride("margin_bottom", UiTokens.Space1);
|
||||
rootVBox.AddChild(contentMargin);
|
||||
|
||||
var contentArea = new Control
|
||||
{
|
||||
@@ -168,11 +228,13 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
||||
};
|
||||
rootVBox.AddChild(contentArea);
|
||||
contentMargin.AddChild(contentArea);
|
||||
|
||||
foreach (var child in existingChildren)
|
||||
contentArea.AddChild(child);
|
||||
|
||||
rootVBox.AddChild(BuildStatusBar());
|
||||
|
||||
AddResizeZones();
|
||||
}
|
||||
|
||||
@@ -183,14 +245,11 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
/// </summary>
|
||||
protected void SetDirty()
|
||||
{
|
||||
if (!_dirty)
|
||||
{
|
||||
if (_dirty)
|
||||
return;
|
||||
|
||||
_dirty = true;
|
||||
var text = "* " + _baseTitle;
|
||||
Title = text;
|
||||
if (_titleLabel != null)
|
||||
_titleLabel.Text = text;
|
||||
}
|
||||
UpdateTitleText();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -199,33 +258,98 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
protected void MarkClean()
|
||||
{
|
||||
_dirty = false;
|
||||
Title = _baseTitle;
|
||||
UpdateTitleText();
|
||||
}
|
||||
|
||||
private void UpdateTitleText()
|
||||
{
|
||||
var text = _dirty ? "* " + _baseTitle : _baseTitle;
|
||||
Title = text;
|
||||
if (_titleLabel != null)
|
||||
_titleLabel.Text = _baseTitle;
|
||||
_titleLabel.Text = text;
|
||||
}
|
||||
|
||||
// ── status bar ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>Shows a message in the status bar, coloured by <paramref name="kind"/>.</summary>
|
||||
protected void SetStatus(string text, ToolStatusKind kind = ToolStatusKind.Info)
|
||||
{
|
||||
_pendingStatus = text ?? string.Empty;
|
||||
_pendingStatusKind = kind;
|
||||
if (_statusLabel == null)
|
||||
return;
|
||||
|
||||
_statusLabel.Text = _pendingStatus;
|
||||
_statusLabel.AddThemeColorOverride("font_color", kind switch
|
||||
{
|
||||
ToolStatusKind.Ok => UiTokens.Ok,
|
||||
ToolStatusKind.Error => UiTokens.DangerText,
|
||||
_ => UiTokens.Body,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Sets the faint right-aligned hint of the status bar (usually the shortcut list).</summary>
|
||||
protected void SetHint(string text)
|
||||
{
|
||||
_pendingHint = text ?? string.Empty;
|
||||
if (_hintLabel != null)
|
||||
_hintLabel.Text = _pendingHint;
|
||||
}
|
||||
|
||||
// ── unsaved-changes dialogs ─────────────────────────────────
|
||||
|
||||
/// <summary>Asks "close without saving?" and runs <paramref name="onDiscard"/> on confirmation.</summary>
|
||||
protected void ShowUnsavedDialog(Action onDiscard)
|
||||
{
|
||||
var dialog = new ConfirmationDialog();
|
||||
dialog.Title = TranslationServer.Translate("tools/unsaved_title");
|
||||
dialog.DialogText = TranslationServer.Translate("tools/unsaved_text");
|
||||
dialog.Confirmed += () => { dialog.QueueFree(); onDiscard(); };
|
||||
dialog.Canceled += () => dialog.QueueFree();
|
||||
ShowConfirm("tools/unsaved_title", "tools/unsaved_text", onDiscard);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="action"/> right away when there are no unsaved
|
||||
/// changes, otherwise only after the author agrees to discard them; on
|
||||
/// refusal <paramref name="onCancel"/> runs (e.g. to roll a file picker
|
||||
/// back). Use before replacing the edited document.
|
||||
/// </summary>
|
||||
protected void RunDiscardingChanges(Action action, Action onCancel = null)
|
||||
{
|
||||
if (!_dirty)
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
|
||||
ShowConfirm("tools/unsaved_title", "tools/unsaved_discard_text", () =>
|
||||
{
|
||||
MarkClean();
|
||||
action();
|
||||
}, onCancel);
|
||||
}
|
||||
|
||||
/// <summary>Shows a themed OK/Cancel dialog; <paramref name="onConfirm"/> runs on OK, <paramref name="onCancel"/> otherwise.</summary>
|
||||
protected void ShowConfirm(string titleKey, string textKey, Action onConfirm, Action onCancel = null)
|
||||
{
|
||||
var dialog = new ConfirmationDialog
|
||||
{
|
||||
Title = TranslationServer.Translate(titleKey),
|
||||
DialogText = TranslationServer.Translate(textKey),
|
||||
Theme = ToolTheme.Get()
|
||||
};
|
||||
dialog.Confirmed += () => { dialog.QueueFree(); onConfirm(); };
|
||||
dialog.Canceled += () => { dialog.QueueFree(); onCancel?.Invoke(); };
|
||||
AddChild(dialog);
|
||||
dialog.PopupCentered();
|
||||
}
|
||||
|
||||
private void HandleCloseRequested()
|
||||
/// <summary>Closes the window, asking first when there are unsaved changes.</summary>
|
||||
protected void RequestClose()
|
||||
{
|
||||
if (_dirty)
|
||||
ShowUnsavedDialog(() => QueueFree());
|
||||
ShowUnsavedDialog(QueueFree);
|
||||
else
|
||||
QueueFree();
|
||||
}
|
||||
|
||||
// ── layout helpers (unchanged API) ──────────────────────────
|
||||
// ── layout helpers ──────────────────────────────────────────
|
||||
|
||||
protected static VBoxContainer CreateFillContainer()
|
||||
{
|
||||
@@ -234,13 +358,14 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
c.AnchorBottom = 1f;
|
||||
c.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
c.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||
c.AddThemeConstantOverride("separation", UiTokens.Space3);
|
||||
return c;
|
||||
}
|
||||
|
||||
protected static HBoxContainer CreateToolbar()
|
||||
{
|
||||
var t = new HBoxContainer();
|
||||
t.AddThemeConstantOverride("separation", 8);
|
||||
t.AddThemeConstantOverride("separation", UiTokens.Space2);
|
||||
return t;
|
||||
}
|
||||
|
||||
@@ -249,26 +374,54 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
var panel = new PanelContainer();
|
||||
panel.CustomMinimumSize = new Vector2(width, 100);
|
||||
panel.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||
panel.AddThemeStyleboxOverride("panel", SidebarBox());
|
||||
|
||||
var scroll = new ScrollContainer();
|
||||
scroll.HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled;
|
||||
scroll.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||
scroll.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
panel.AddChild(scroll);
|
||||
content.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
content.AddThemeConstantOverride("separation", UiTokens.Space2);
|
||||
scroll.AddChild(content);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
protected static Font GetMonospaceFont()
|
||||
/// <summary>Inset sidebar surface: <c>--ct-surface-2</c>, line border, card radius.</summary>
|
||||
protected static StyleBoxFlat SidebarBox()
|
||||
{
|
||||
try
|
||||
var style = new StyleBoxFlat
|
||||
{
|
||||
if (ResourceLoader.Exists("res://assets/fonts/RobotoMono.ttf"))
|
||||
return ResourceLoader.Load<FontFile>("res://assets/fonts/RobotoMono.ttf");
|
||||
BgColor = UiTokens.Surface2,
|
||||
BorderColor = UiTokens.Line,
|
||||
BorderWidthLeft = 1,
|
||||
BorderWidthRight = 1,
|
||||
BorderWidthTop = 1,
|
||||
BorderWidthBottom = 1,
|
||||
CornerRadiusTopLeft = UiTokens.RadiusMd,
|
||||
CornerRadiusTopRight = UiTokens.RadiusMd,
|
||||
CornerRadiusBottomLeft = UiTokens.RadiusMd,
|
||||
CornerRadiusBottomRight = UiTokens.RadiusMd,
|
||||
ContentMarginLeft = UiTokens.Space4,
|
||||
ContentMarginRight = UiTokens.Space4,
|
||||
ContentMarginTop = UiTokens.Space4,
|
||||
ContentMarginBottom = UiTokens.Space4
|
||||
};
|
||||
return style;
|
||||
}
|
||||
catch { }
|
||||
return ThemeDB.FallbackFont;
|
||||
|
||||
/// <summary>
|
||||
/// Monospace font for code, IDs and numbers (style guide: <c>--ct-mono</c>).
|
||||
/// The game ships no mono font, so the OS one is requested by name with the
|
||||
/// guide's fallbacks; Godot falls back to its default font if none exist.
|
||||
/// </summary>
|
||||
public static Font GetMonospaceFont()
|
||||
{
|
||||
return _monospaceFont ??= new SystemFont
|
||||
{
|
||||
FontNames = new[] { "Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Liberation Mono", "Courier New", "monospace" }
|
||||
};
|
||||
}
|
||||
|
||||
// ── chrome building ─────────────────────────────────────────
|
||||
@@ -290,10 +443,14 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
BorderWidthBottom = 1,
|
||||
BorderWidthTop = 1,
|
||||
BorderColor = UiTokens.Line,
|
||||
ContentMarginLeft = 4,
|
||||
ContentMarginRight = 4,
|
||||
ContentMarginBottom = 4,
|
||||
ContentMarginTop = 4
|
||||
CornerRadiusTopLeft = UiTokens.RadiusLg,
|
||||
CornerRadiusTopRight = UiTokens.RadiusLg,
|
||||
CornerRadiusBottomLeft = UiTokens.RadiusLg,
|
||||
CornerRadiusBottomRight = UiTokens.RadiusLg,
|
||||
ContentMarginLeft = 1,
|
||||
ContentMarginRight = 1,
|
||||
ContentMarginBottom = 1,
|
||||
ContentMarginTop = 1
|
||||
};
|
||||
panel.AddThemeStyleboxOverride("panel", style);
|
||||
AddChild(panel);
|
||||
@@ -308,25 +465,26 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
Name = "TitleBar",
|
||||
CustomMinimumSize = new Vector2(0, TITLE_BAR_HEIGHT)
|
||||
};
|
||||
_titleBar.AddThemeConstantOverride("separation", UiTokens.Space1);
|
||||
|
||||
_titleLabel = new Label
|
||||
{
|
||||
Text = _dirty ? "* " + _baseTitle : _baseTitle,
|
||||
Uppercase = true,
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
MouseFilter = Control.MouseFilterEnum.Ignore
|
||||
};
|
||||
_titleLabel.AddThemeColorOverride("font_color", UiTokens.InkBright);
|
||||
_titleLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontLead);
|
||||
_titleBar.AddChild(_titleLabel);
|
||||
|
||||
_maximizeBtn = CreateTitleButton("\u25A1", OnMaximizeToggle,
|
||||
UiTokens.Muted);
|
||||
CthangoverTheme.ApplyIcon(_maximizeBtn);
|
||||
_maximizeBtn = CreateTitleButton("□", OnMaximizeToggle, UiTokens.BtnHover, UiTokens.InkBright);
|
||||
_maximizeBtn.TooltipText = TranslationServer.Translate("tools/window_maximize");
|
||||
_titleBar.AddChild(_maximizeBtn);
|
||||
|
||||
var closeBtn = CreateTitleButton("\u2715", OnClose,
|
||||
UiTokens.Danger);
|
||||
CthangoverTheme.ApplyDanger(closeBtn);
|
||||
var closeBtn = CreateTitleButton("✕", RequestClose, UiTokens.DangerSoft, UiTokens.DangerText);
|
||||
closeBtn.TooltipText = TranslationServer.Translate("tools/window_close");
|
||||
_titleBar.AddChild(closeBtn);
|
||||
|
||||
_titleBar.GuiInput += OnTitleBarInput;
|
||||
@@ -336,39 +494,93 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
|
||||
var titleBg = new StyleBoxFlat
|
||||
{
|
||||
BgColor = UiTokens.Surface2,
|
||||
ContentMarginLeft = 10,
|
||||
ContentMarginRight = 2,
|
||||
BgColor = UiTokens.Surface1,
|
||||
BorderColor = UiTokens.Line,
|
||||
BorderWidthBottom = 1,
|
||||
CornerRadiusTopLeft = UiTokens.RadiusLg,
|
||||
CornerRadiusTopRight = UiTokens.RadiusLg,
|
||||
ContentMarginLeft = UiTokens.Space7,
|
||||
ContentMarginRight = UiTokens.Space1,
|
||||
};
|
||||
titlePanel.AddThemeStyleboxOverride("panel", titleBg);
|
||||
|
||||
return titlePanel;
|
||||
}
|
||||
|
||||
private static Button CreateTitleButton(string text, Action pressed, Color hoverColor)
|
||||
private PanelContainer BuildStatusBar()
|
||||
{
|
||||
var bar = new HBoxContainer { Name = "StatusBar", CustomMinimumSize = new Vector2(0, 26) };
|
||||
bar.AddThemeConstantOverride("separation", UiTokens.Space5);
|
||||
|
||||
_statusLabel = new Label
|
||||
{
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis
|
||||
};
|
||||
_statusLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
||||
bar.AddChild(_statusLabel);
|
||||
|
||||
_hintLabel = new Label
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
_hintLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
|
||||
_hintLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||
bar.AddChild(_hintLabel);
|
||||
|
||||
SetStatus(_pendingStatus, _pendingStatusKind);
|
||||
SetHint(_pendingHint);
|
||||
|
||||
var panel = new PanelContainer();
|
||||
panel.AddChild(bar);
|
||||
var style = new StyleBoxFlat
|
||||
{
|
||||
BgColor = UiTokens.Surface2,
|
||||
BorderColor = UiTokens.Line,
|
||||
BorderWidthTop = 1,
|
||||
CornerRadiusBottomLeft = UiTokens.RadiusLg,
|
||||
CornerRadiusBottomRight = UiTokens.RadiusLg,
|
||||
ContentMarginLeft = UiTokens.Space5,
|
||||
ContentMarginRight = UiTokens.Space5,
|
||||
};
|
||||
panel.AddThemeStyleboxOverride("panel", style);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static Button CreateTitleButton(string text, Action pressed, Color hoverBg, Color hoverText)
|
||||
{
|
||||
var btn = new Button
|
||||
{
|
||||
Text = text,
|
||||
CustomMinimumSize = new Vector2(36, 28),
|
||||
CustomMinimumSize = new Vector2(TITLE_BUTTON_SIZE, TITLE_BUTTON_SIZE),
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ShrinkEnd,
|
||||
SizeFlagsVertical = Control.SizeFlags.ShrinkCenter,
|
||||
Flat = true
|
||||
Flat = true,
|
||||
FocusMode = Control.FocusModeEnum.None
|
||||
};
|
||||
|
||||
btn.AddThemeColorOverride("font_color", UiTokens.Ink);
|
||||
btn.AddThemeColorOverride("font_hover_color", UiTokens.InkBright);
|
||||
btn.AddThemeColorOverride("font_hover_pressed_color", UiTokens.InkBright);
|
||||
btn.AddThemeFontSizeOverride("font_size", UiTokens.FontBodyLarge);
|
||||
btn.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
btn.AddThemeColorOverride("font_hover_color", hoverText);
|
||||
btn.AddThemeColorOverride("font_pressed_color", hoverText);
|
||||
btn.AddThemeColorOverride("font_hover_pressed_color", hoverText);
|
||||
btn.AddThemeFontSizeOverride("font_size", UiTokens.FontLead);
|
||||
|
||||
var normalStyle = new StyleBoxFlat { BgColor = Colors.Transparent };
|
||||
btn.AddThemeStyleboxOverride("normal", normalStyle);
|
||||
|
||||
var hoverStyle = new StyleBoxFlat { BgColor = hoverColor };
|
||||
var hoverStyle = new StyleBoxFlat
|
||||
{
|
||||
BgColor = hoverBg,
|
||||
CornerRadiusTopLeft = UiTokens.RadiusSm,
|
||||
CornerRadiusTopRight = UiTokens.RadiusSm,
|
||||
CornerRadiusBottomLeft = UiTokens.RadiusSm,
|
||||
CornerRadiusBottomRight = UiTokens.RadiusSm
|
||||
};
|
||||
btn.AddThemeStyleboxOverride("hover", hoverStyle);
|
||||
btn.AddThemeStyleboxOverride("pressed", hoverStyle);
|
||||
|
||||
btn.AddThemeStyleboxOverride("focus", normalStyle);
|
||||
btn.AddThemeStyleboxOverride("focus", new StyleBoxEmpty());
|
||||
|
||||
btn.Pressed += pressed;
|
||||
return btn;
|
||||
@@ -389,27 +601,21 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
_restorePosition = Position;
|
||||
_restoreSize = Size;
|
||||
_isMaximized = true;
|
||||
_maximizeBtn.Text = "\u2750";
|
||||
_maximizeBtn.Text = "❐";
|
||||
|
||||
Position = Vector2I.Zero;
|
||||
Size = (Vector2I)GetGameViewportSize();
|
||||
GameLogger.Log("TOOL_WINDOW", $"MaximizeWindow gameViewport=({GetGameViewportSize().X:F0},{GetGameViewportSize().Y:F0}) physWindow=({DisplayServer.WindowGetSize().X},{DisplayServer.WindowGetSize().Y}) newSize=({Size.X},{Size.Y})");
|
||||
}
|
||||
|
||||
private void RestoreWindow()
|
||||
{
|
||||
_isMaximized = false;
|
||||
_maximizeBtn.Text = "\u25A1";
|
||||
_maximizeBtn.Text = "□";
|
||||
|
||||
Position = _restorePosition;
|
||||
Size = _restoreSize;
|
||||
}
|
||||
|
||||
private void OnClose()
|
||||
{
|
||||
HandleCloseRequested();
|
||||
}
|
||||
|
||||
// ── title bar drag ──────────────────────────────────────────
|
||||
|
||||
private void OnTitleBarInput(InputEvent evt)
|
||||
@@ -429,7 +635,7 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
if (_isMaximized)
|
||||
{
|
||||
_isMaximized = false;
|
||||
_maximizeBtn.Text = "\u25A1";
|
||||
_maximizeBtn.Text = "□";
|
||||
var pos = DisplayServer.MouseGetPosition();
|
||||
var viewportSize = GetGameViewportSize();
|
||||
var ratio = pos.X / viewportSize.X;
|
||||
@@ -437,6 +643,7 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
Mathf.RoundToInt(pos.X - _restoreSize.X * ratio),
|
||||
Mathf.RoundToInt(pos.Y - 4));
|
||||
_titleDragMouseStart = new Vector2(pos.X, pos.Y);
|
||||
Size = _restoreSize;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -452,7 +659,7 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (evt is InputEventMouseMotion mm && _draggingTitle)
|
||||
else if (evt is InputEventMouseMotion && _draggingTitle)
|
||||
{
|
||||
var mp = DisplayServer.MouseGetPosition();
|
||||
var delta = new Vector2(mp.X, mp.Y) - _titleDragMouseStart;
|
||||
@@ -589,49 +796,23 @@ namespace Cthangover.Mods.Interface.Tool
|
||||
private void ApplyResize(Vector2 globalMouse)
|
||||
{
|
||||
var delta = globalMouse - _resizeMouseStart;
|
||||
var dx = Mathf.RoundToInt(delta.X);
|
||||
var dy = Mathf.RoundToInt(delta.Y);
|
||||
|
||||
int newX = _resizePosStart.X;
|
||||
int newY = _resizePosStart.Y;
|
||||
int newW = _resizeSizeStart.X;
|
||||
int newH = _resizeSizeStart.Y;
|
||||
|
||||
switch (_resizeEdge)
|
||||
{
|
||||
case ResizeEdge.Left:
|
||||
newX = _resizePosStart.X + Mathf.RoundToInt(delta.X);
|
||||
newW = _resizeSizeStart.X - Mathf.RoundToInt(delta.X);
|
||||
break;
|
||||
case ResizeEdge.Right:
|
||||
newW = _resizeSizeStart.X + Mathf.RoundToInt(delta.X);
|
||||
break;
|
||||
case ResizeEdge.Top:
|
||||
newY = _resizePosStart.Y + Mathf.RoundToInt(delta.Y);
|
||||
newH = _resizeSizeStart.Y - Mathf.RoundToInt(delta.Y);
|
||||
break;
|
||||
case ResizeEdge.Bottom:
|
||||
newH = _resizeSizeStart.Y + Mathf.RoundToInt(delta.Y);
|
||||
break;
|
||||
case ResizeEdge.TopLeft:
|
||||
newX = _resizePosStart.X + Mathf.RoundToInt(delta.X);
|
||||
newY = _resizePosStart.Y + Mathf.RoundToInt(delta.Y);
|
||||
newW = _resizeSizeStart.X - Mathf.RoundToInt(delta.X);
|
||||
newH = _resizeSizeStart.Y - Mathf.RoundToInt(delta.Y);
|
||||
break;
|
||||
case ResizeEdge.TopRight:
|
||||
newY = _resizePosStart.Y + Mathf.RoundToInt(delta.Y);
|
||||
newW = _resizeSizeStart.X + Mathf.RoundToInt(delta.X);
|
||||
newH = _resizeSizeStart.Y - Mathf.RoundToInt(delta.Y);
|
||||
break;
|
||||
case ResizeEdge.BottomLeft:
|
||||
newX = _resizePosStart.X + Mathf.RoundToInt(delta.X);
|
||||
newW = _resizeSizeStart.X - Mathf.RoundToInt(delta.X);
|
||||
newH = _resizeSizeStart.Y + Mathf.RoundToInt(delta.Y);
|
||||
break;
|
||||
case ResizeEdge.BottomRight:
|
||||
newW = _resizeSizeStart.X + Mathf.RoundToInt(delta.X);
|
||||
newH = _resizeSizeStart.Y + Mathf.RoundToInt(delta.Y);
|
||||
break;
|
||||
}
|
||||
var left = _resizeEdge is ResizeEdge.Left or ResizeEdge.TopLeft or ResizeEdge.BottomLeft;
|
||||
var right = _resizeEdge is ResizeEdge.Right or ResizeEdge.TopRight or ResizeEdge.BottomRight;
|
||||
var top = _resizeEdge is ResizeEdge.Top or ResizeEdge.TopLeft or ResizeEdge.TopRight;
|
||||
var bottom = _resizeEdge is ResizeEdge.Bottom or ResizeEdge.BottomLeft or ResizeEdge.BottomRight;
|
||||
|
||||
if (left) { newX += dx; newW -= dx; }
|
||||
if (right) newW += dx;
|
||||
if (top) { newY += dy; newH -= dy; }
|
||||
if (bottom) newH += dy;
|
||||
|
||||
newX = Mathf.Clamp(newX, 0, MaxSize.X - 100);
|
||||
newY = Mathf.Clamp(newY, 0, MaxSize.Y - 100);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Reference in new issue
Block a user