From b2a0f1c10ace48e8bd57e6955f6be3f35014e94c Mon Sep 17 00:00:00 2001 From: iisaev Date: Sat, 17 Oct 2026 14:27:19 +0000 Subject: [PATCH] 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. --- locale/ui_en.properties | 7 + locale/ui_ru.properties | 7 + src/Tool/ToolBox.cs | 49 ++-- src/Tool/ToolStatusKind.cs | 14 ++ src/Tool/ToolTheme.cs | 53 +++++ src/Tool/ToolWindow.cs | 451 ++++++++++++++++++++++++++----------- ui/icons/tools.png | Bin 0 -> 2236 bytes 7 files changed, 430 insertions(+), 151 deletions(-) create mode 100644 src/Tool/ToolStatusKind.cs create mode 100644 src/Tool/ToolTheme.cs create mode 100644 ui/icons/tools.png 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 0000000000000000000000000000000000000000..85f407fa3b4203800352d15af1d4751a4b31cd1a GIT binary patch literal 2236 zcmbVOdsq`!9=$UJ84VOttx*IFp}1&;Kv4ulX5?9{pD5Zb6%nc~EL0wqN2Cf#L>2<_ z@PQPShaYu)qM$-_c}#Xi2#*p_wrD|u7>R}Q3Md&FlD%R7-R-yAe`e<1bMCq4{Jxpr z9r4Z`{w7PzmjD1v0zUHz0sw)J2*4J>+rA^$A^^bB1AMmbIr{V1i>cE?F2HZf{-mE> zQ^o_t_??rX_voQR793El7Fca!3BjSZFkgTc*pnCm{_&bEN_A7HhDg`NLf^X*n%lMG z-COo+&sw>KWUeeH=R`)rDVjU!`3rB=NnLCGzHtEw%EdIds-#9Vb2{OmljSEQHnTRZ z<5KXRmUbSBuX@$clz)MlbJLXa9h6@hXRFpxhT+L@pkQlam2Bb1ly7+1)W%`7Ykp{Z zt~7&22MgCGe7#)F1e7z|jxrR|mzn=OmWEWhdb8J4hSDM9&fCq0?KV&$ zTi*4JX2>V368AzdN-gMd3DnxBw<=!rpDv>-2DEh_*a`T(h%8XskgSQB8X*%mtdSpY z+zMo?Z3H({g5Aw8M&kX6(BM^>;($Y}Wp4NRKoCz6YfIXB zK$(9=msLq_YtdJwLWy&Meaoa}-AWkbpb}pP?3>l0A;te59Wdxxe(-%~sxhpblX})M z@QMwE=ih^RzS{l6Hxjvmv8dSfc53ZiS<%)&;tknU!=CfVukJ}qvoY;m(Du^$FZp3T)N*8ycFbThNb>r+Wg z-ZYl5@rmSm7N8m?%6_#MVjUkLYQCmkFu_KA!}G8Lfiyp5cIK2Orb^r>29*DK@+vsB zO!VaM^1j^+8QyhFTWa9w#n|SL$Cw`FvW{9A_Xa zGZ)Q7&h>Ubp4bAm_?8<_PLHn?9in(icTJ;ntI|7S0JWRjl|yG}+%BH5vi)ME<6e6k zIBtLBDk#mC{Mwp0|PYP>jgm?7f05-+ux+nZ+SCL>iJMCax#!m-U+?Y9_Ir zv8`m=_sZ#6Jg7xR#U_b zQF{aE!3?lCID^m|ShQ7tg#c3zpb#Lz0e z@-fab11H;fC@PGndiyw)01JV`ETTFsi~#9 zH4yfo$Yiyf7_Ql^HHC`~{l=pIYX_yN-j9%8FjRmqI8tZo)d}rF0NLt=1k@vagcaG- zp||B@)$ImFa{?QfOnUOp$d$Zr*Qe*#U>LESNXA3C1uFsMU?&#|VL{N#NHC&pyv=sC z*x}yH#U$tTLiGEZQwN ztxUV>6SSKh9XbCv@4eSf$$WD*L5E%_7CiJyJH8Z={CSjCd~nR{{cjclg})3P15L|6#i3 literal 0 HcmV?d00001