diff --git a/locale/ui_en.properties b/locale/ui_en.properties
index a810984..7a3c136 100644
--- a/locale/ui_en.properties
+++ b/locale/ui_en.properties
@@ -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
diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties
index 19d13fb..f988597 100644
--- a/locale/ui_ru.properties
+++ b/locale/ui_ru.properties
@@ -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 = Инструменты
diff --git a/src/Tool/ToolBox.cs b/src/Tool/ToolBox.cs
index 512501f..578c84a 100644
--- a/src/Tool/ToolBox.cs
+++ b/src/Tool/ToolBox.cs
@@ -89,29 +89,46 @@ namespace Cthangover.Mods.Interface.Tool
return button;
}
+ ///
+ /// Adds one "tools" toolbar button whose popup lists every visible
+ /// . 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.
+ ///
private void AddToolButtons(HBoxContainer container)
{
if (container == null)
return;
- foreach (var buttonDef in ToolBoxButtonFactory.Instance.GetVisible())
+ var visible = new List(ToolBoxButtonFactory.Instance.GetVisible());
+ if (visible.Count == 0)
+ return;
+
+ visible.Sort((a, b) => string.Compare(
+ TranslationServer.Translate(a.LocaleKey), TranslationServer.Translate(b.LocaleKey),
+ System.StringComparison.CurrentCulture));
+
+ 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 toolId = buttonDef.ToolId;
- var title = TranslationServer.Translate(buttonDef.LocaleKey);
+ var rect = btn.GetGlobalRect();
+ menu.Position = new Vector2I((int)rect.Position.X, (int)rect.End.Y + UiTokens.Space1);
+ menu.Popup();
+ };
+ container.AddChild(btn);
+ }
- Texture2D icon = null;
- if (!string.IsNullOrEmpty(buttonDef.IconPath) && ResourceLoader.Exists(buttonDef.IconPath))
- icon = ResourceLoader.Load(buttonDef.IconPath);
-
- var btn = CreateToolbarButton(title, icon);
- btn.Pressed += () =>
- {
- var tool = ToolFactory.Instance.Get(toolId);
- if (tool != null)
- ToolWindow.ShowWindow(tool.CreateWindow());
- };
- container.AddChild(btn);
- }
+ private static void OpenTool(string toolId)
+ {
+ var tool = ToolFactory.Instance.Get(toolId);
+ if (tool != null)
+ ToolWindow.ShowWindow(tool.CreateWindow());
}
///
diff --git a/src/Tool/ToolStatusKind.cs b/src/Tool/ToolStatusKind.cs
new file mode 100644
index 0000000..074fb77
--- /dev/null
+++ b/src/Tool/ToolStatusKind.cs
@@ -0,0 +1,14 @@
+namespace Cthangover.Mods.Interface.Tool
+{
+ ///
+ /// Tone of a message in the status bar, mapped to the
+ /// style guide's inline messages: neutral info, ok (.ct-message--ok)
+ /// and error (.ct-message--err).
+ ///
+ public enum ToolStatusKind
+ {
+ Info,
+ Ok,
+ Error,
+ }
+}
diff --git a/src/Tool/ToolTheme.cs b/src/Tool/ToolTheme.cs
new file mode 100644
index 0000000..9f5e376
--- /dev/null
+++ b/src/Tool/ToolTheme.cs
@@ -0,0 +1,53 @@
+using Cthangover.Core.UI.Theming;
+using Godot;
+
+namespace Cthangover.Mods.Interface.Tool
+{
+ ///
+ /// Dense variant of 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.
+ ///
+ public static class ToolTheme
+ {
+ private const int MaxVerticalMargin = 5;
+ private const int MaxHorizontalMargin = 10;
+
+ private static Theme instance;
+
+ /// Shared dense theme instance, built lazily from the game theme.
+ 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;
+ }
+ }
+}
diff --git a/src/Tool/ToolWindow.cs b/src/Tool/ToolWindow.cs
index a5ee683..564cc5e 100644
--- a/src/Tool/ToolWindow.cs
+++ b/src/Tool/ToolWindow.cs
@@ -8,23 +8,34 @@ using Cthangover.Core.UI.Tool;
namespace Cthangover.Mods.Interface.Tool
{
///
- /// 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 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.
+ ///
+ /// Subclasses build their UI in the constructor and add it as direct
+ /// children; (run by and
+ /// ) 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.
+ ///
+ /// Tool windows use , a denser copy of the game
+ /// theme: same tokens, smaller paddings, so editor toolbars fit.
+ ///
+ /// Shortcuts: Ctrl+S calls even while a text
+ /// field has focus (checked in _Input, before the GUI); every other
+ /// key reaches only if no focused control consumed
+ /// it (_UnhandledInput), so Delete in a text field never deletes a
+ /// selected object.
///
public abstract partial class ToolWindow : Window
{
- // ── existing fields ──────────────────────────────────────────
-
///
/// Whether the window has unsaved changes. When true, the title bar shows a "*" prefix
- /// and prompts for confirmation before closing.
+ /// and closing asks for confirmation first.
///
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 ─────────────────────────────────────────
+ ///
+ /// 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.
+ ///
+ 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() 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();
- CthangoverTheme.Apply(window);
+ 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;
}
/// Runs the teardown hook when the window leaves the tree.
@@ -114,18 +136,46 @@ namespace Cthangover.Mods.Interface.Tool
Cleanup();
}
+ /// Ctrl+S → , checked before the GUI so it works inside text fields.
+ 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();
+ }
+ }
+
+ /// Forwards keys no control consumed to .
+ public override void _UnhandledInput(InputEvent @event)
+ {
+ if (@event is InputEventKey key && key.Pressed && HandleKey(key))
+ SetInputAsHandled();
+ }
+
///
/// Override to release resources when the window is closed. Called from _ExitTree.
///
protected virtual void Cleanup() { }
+ /// Ctrl+S handler; tools with a save action override it.
+ protected virtual void OnSaveShortcut() { }
+
+ ///
+ /// Tool-specific shortcut for a key no focused control consumed.
+ /// Return true when handled.
+ ///
+ protected virtual bool HandleKey(InputEventKey key) => false;
+
// ── chrome setup ────────────────────────────────────────────
///
- /// Wraps all currently-added children inside a custom chrome layout
- /// (title bar + border + resize zones). Called automatically by
- /// and 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
+ /// and ; safe to call twice.
///
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();
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
///
protected void SetDirty()
{
- if (!_dirty)
- {
- _dirty = true;
- var text = "* " + _baseTitle;
- Title = text;
- if (_titleLabel != null)
- _titleLabel.Text = text;
- }
+ if (_dirty)
+ return;
+
+ _dirty = true;
+ UpdateTitleText();
}
///
@@ -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 ──────────────────────────────────────────────
+
+ /// Shows a message in the status bar, coloured by .
+ 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,
+ });
+ }
+
+ /// Sets the faint right-aligned hint of the status bar (usually the shortcut list).
+ protected void SetHint(string text)
+ {
+ _pendingHint = text ?? string.Empty;
+ if (_hintLabel != null)
+ _hintLabel.Text = _pendingHint;
}
// ── unsaved-changes dialogs ─────────────────────────────────
+ /// Asks "close without saving?" and runs on confirmation.
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);
+ }
+
+ ///
+ /// Runs right away when there are no unsaved
+ /// changes, otherwise only after the author agrees to discard them; on
+ /// refusal runs (e.g. to roll a file picker
+ /// back). Use before replacing the edited document.
+ ///
+ protected void RunDiscardingChanges(Action action, Action onCancel = null)
+ {
+ if (!_dirty)
+ {
+ action();
+ return;
+ }
+
+ ShowConfirm("tools/unsaved_title", "tools/unsaved_discard_text", () =>
+ {
+ MarkClean();
+ action();
+ }, onCancel);
+ }
+
+ /// Shows a themed OK/Cancel dialog; runs on OK, otherwise.
+ 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()
+ /// Closes the window, asking first when there are unsaved changes.
+ 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()
+ /// Inset sidebar surface: --ct-surface-2, line border, card radius.
+ protected static StyleBoxFlat SidebarBox()
{
- try
+ var style = new StyleBoxFlat
{
- if (ResourceLoader.Exists("res://assets/fonts/RobotoMono.ttf"))
- return ResourceLoader.Load("res://assets/fonts/RobotoMono.ttf");
- }
- catch { }
- return ThemeDB.FallbackFont;
+ 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;
+ }
+
+ ///
+ /// Monospace font for code, IDs and numbers (style guide: --ct-mono).
+ /// 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.
+ ///
+ 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);
diff --git a/ui/icons/tools.png b/ui/icons/tools.png
new file mode 100644
index 0000000..85f407f
Binary files /dev/null and b/ui/icons/tools.png differ