From bc6735bd23df7e286c081221414fdd898336f41b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 3 Jan 2026 10:12:22 -0800 Subject: [PATCH] feat: Adds grid support for the DynamicGump system (#2306) ### Summary - Adds a new grid layout system for DynamicGump with zero heap allocations - Migrates SpawnerControllerGump from legacy GumpGrid to DynamicGump - Migrates CommandListGump from BaseGridGump to DynamicGump - Removes legacy GumpGrid (no longer needed) ### New Grid Layout Components | Component | Purpose | |-----------|---------| | `GridCell` | Value type for cell bounds (x, y, width, height) | | `GridSizeSpec` | Parses CSS-like sizing specs ("10*", "*", "100") | | `GridCalculator` | Computes track positions using stackalloc | | `ListViewLayout` | Pagination + column layout for list views | | `GridBuilderExtensions` | Extension methods accepting `GridCell` | | `GridEntryStyle` | Styling properties for BaseGridGump migration | | `GridEntryExtensions` | Entry methods matching BaseGridGump patterns | ### Memory Impact | Component | Legacy | New | |-----------|--------|-----| | Grid storage | ~200 bytes heap | 0 bytes (stackalloc) | | Column/Row lists | ~400 bytes heap | ~128 bytes stack | | ListView | ~300 bytes heap | ~64 bytes stack | | **Total per render** | **~900 bytes heap** | **0 bytes heap** | ### Test plan - [x] All 21 gump tests pass - [x] Build succeeds with no warnings - [ ] In-game verification of SpawnerControllerGump - [ ] In-game verification of CommandListGump (HelpInfo command) --- Projects/Server/Utilities/Utility.cs | 4 +- .../Tests/Gumps/GridLayoutTests.cs | 302 +++++++ Projects/UOContent/Commands/HelpInfo.cs | 288 ++++--- .../Engines/Spawners/SpawnerControllerGump.cs | 737 ++++++++++-------- .../Base/GridLayout/GridBuilderExtensions.cs | 512 ++++++++++++ .../Gumps/Base/GridLayout/GridCalculator.cs | 242 ++++++ .../Gumps/Base/GridLayout/GridCell.cs | 114 +++ .../Base/GridLayout/GridEntryExtensions.cs | 222 ++++++ .../Gumps/Base/GridLayout/GridEntryStyle.cs | 165 ++++ .../Gumps/Base/GridLayout/GridSizeSpec.cs | 162 ++++ .../Gumps/Base/GridLayout/ListViewLayout.cs | 239 ++++++ Projects/UOContent/Gumps/Base/GumpColors.cs | 22 + .../UOContent/Gumps/Base/Legacy/GumpGrid.cs | 585 -------------- 13 files changed, 2579 insertions(+), 1015 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs create mode 100644 Projects/UOContent/Gumps/Base/GridLayout/GridBuilderExtensions.cs create mode 100644 Projects/UOContent/Gumps/Base/GridLayout/GridCalculator.cs create mode 100644 Projects/UOContent/Gumps/Base/GridLayout/GridCell.cs create mode 100644 Projects/UOContent/Gumps/Base/GridLayout/GridEntryExtensions.cs create mode 100644 Projects/UOContent/Gumps/Base/GridLayout/GridEntryStyle.cs create mode 100644 Projects/UOContent/Gumps/Base/GridLayout/GridSizeSpec.cs create mode 100644 Projects/UOContent/Gumps/Base/GridLayout/ListViewLayout.cs create mode 100644 Projects/UOContent/Gumps/Base/GumpColors.cs delete mode 100644 Projects/UOContent/Gumps/Base/Legacy/GumpGrid.cs diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index f68ff9ffb..333e1e7bd 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -209,7 +209,7 @@ public static partial class Utility var span = chars.AsSpan(0, str.Length); str.CopyTo(span); - FixHtml(span); + span.FixHtml(); var fixedStr = span.ToString(); STArrayPool.Shared.Return(chars); @@ -243,7 +243,7 @@ public static partial class Utility if (!str.IsNullOrWhiteSpace()) { - FixHtml(span); + span.FixHtml(); } return formattable; diff --git a/Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs b/Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs new file mode 100644 index 000000000..0f4518289 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs @@ -0,0 +1,302 @@ +using System; +using Server.Gumps; +using Xunit; + +namespace Server.Tests.Gumps; + +public class GridLayoutTests +{ + [Fact] + public void TestGridSizeSpec_ParseAbsolute() + { + var spec = GridSizeSpec.Parse("100"); + Assert.Equal(GridSizeType.Absolute, spec.Type); + Assert.Equal(100, spec.Value); + } + + [Fact] + public void TestGridSizeSpec_ParseStar() + { + var spec = GridSizeSpec.Parse("*"); + Assert.Equal(GridSizeType.Star, spec.Type); + Assert.Equal(1, spec.Value); + } + + [Fact] + public void TestGridSizeSpec_ParsePercent() + { + var spec = GridSizeSpec.Parse("10*"); + Assert.Equal(GridSizeType.Percent, spec.Type); + Assert.Equal(10, spec.Value); + } + + [Fact] + public void TestGridSizeSpec_ParseAll() + { + Span specs = stackalloc GridSizeSpec[4]; + var count = GridSizeSpec.ParseAll("10* * 100 20*", specs); + + Assert.Equal(4, count); + Assert.Equal(GridSizeType.Percent, specs[0].Type); + Assert.Equal(10, specs[0].Value); + Assert.Equal(GridSizeType.Star, specs[1].Type); + Assert.Equal(GridSizeType.Absolute, specs[2].Type); + Assert.Equal(100, specs[2].Value); + Assert.Equal(GridSizeType.Percent, specs[3].Type); + Assert.Equal(20, specs[3].Value); + } + + [Fact] + public void TestGridCalculator_UniformTracks() + { + Span positions = stackalloc int[4]; + Span sizes = stackalloc int[4]; + + GridCalculator.ComputeUniformTrackSizes(4, 400, 0, positions, sizes); + + Assert.Equal(0, positions[0]); + Assert.Equal(100, positions[1]); + Assert.Equal(200, positions[2]); + Assert.Equal(300, positions[3]); + + Assert.Equal(100, sizes[0]); + Assert.Equal(100, sizes[1]); + Assert.Equal(100, sizes[2]); + Assert.Equal(100, sizes[3]); + } + + [Fact] + public void TestGridCalculator_UniformTracksWithOrigin() + { + Span positions = stackalloc int[2]; + Span sizes = stackalloc int[2]; + + GridCalculator.ComputeUniformTrackSizes(2, 200, 50, positions, sizes); + + Assert.Equal(50, positions[0]); + Assert.Equal(150, positions[1]); + Assert.Equal(100, sizes[0]); + Assert.Equal(100, sizes[1]); + } + + [Fact] + public void TestGridCalculator_MixedTracks() + { + // "10* * 100" = 10%, star, 100px absolute + Span positions = stackalloc int[3]; + Span sizes = stackalloc int[3]; + + var count = GridCalculator.ComputeFromSpec("10* * 100", 1000, 0, positions, sizes); + + Assert.Equal(3, count); + + // 10% of 1000 = 100 + Assert.Equal(0, positions[0]); + Assert.Equal(100, sizes[0]); + + // 100px absolute + Assert.Equal(900, positions[2]); + Assert.Equal(100, sizes[2]); + + // Star gets remaining: 1000 - 100 - 100 = 800 + Assert.Equal(100, positions[1]); + Assert.Equal(800, sizes[1]); + } + + [Fact] + public void TestGridCell_Properties() + { + var cell = new GridCell(10, 20, 100, 50); + + Assert.Equal(10, cell.X); + Assert.Equal(20, cell.Y); + Assert.Equal(100, cell.Width); + Assert.Equal(50, cell.Height); + Assert.Equal(110, cell.Right); + Assert.Equal(70, cell.Bottom); + Assert.Equal(60, cell.CenterX); + Assert.Equal(45, cell.CenterY); + } + + [Fact] + public void TestGridCell_Inset() + { + var cell = new GridCell(10, 20, 100, 50); + var inset = cell.Inset(5); + + Assert.Equal(15, inset.X); + Assert.Equal(25, inset.Y); + Assert.Equal(90, inset.Width); + Assert.Equal(40, inset.Height); + } + + [Fact] + public void TestGridCell_Offset() + { + var cell = new GridCell(10, 20, 100, 50); + var offset = cell.Offset(5, 10); + + Assert.Equal(15, offset.X); + Assert.Equal(30, offset.Y); + Assert.Equal(100, offset.Width); + Assert.Equal(50, offset.Height); + } + + [Fact] + public void TestListViewLayout_Pagination() + { + Span colPos = stackalloc int[3]; + Span colWidths = stackalloc int[3]; + + // 100 items, row height 20, header 10, total height 210 (10 items per page) + var layout = ListViewLayout.Create( + 0, 0, 300, 210, + 100, // total items + 0, // current page + 20, // row height + 10, // header height + "* * *", + colPos, colWidths); + + Assert.Equal(100, layout.TotalItems); + Assert.Equal(10, layout.ItemsPerPage); + Assert.Equal(0, layout.CurrentPage); + Assert.Equal(10, layout.TotalPages); + Assert.Equal(0, layout.StartIndex); + Assert.Equal(10, layout.VisibleCount); + Assert.False(layout.CanGoBack); + Assert.True(layout.CanGoNext); + } + + [Fact] + public void TestListViewLayout_MiddlePage() + { + Span colPos = stackalloc int[3]; + Span colWidths = stackalloc int[3]; + + var layout = ListViewLayout.Create( + 0, 0, 300, 210, + 100, // total items + 5, // current page (middle) + 20, // row height + 10, // header height + "* * *", + colPos, colWidths); + + Assert.Equal(5, layout.CurrentPage); + Assert.Equal(50, layout.StartIndex); + Assert.Equal(10, layout.VisibleCount); + Assert.True(layout.CanGoBack); + Assert.True(layout.CanGoNext); + } + + [Fact] + public void TestListViewLayout_LastPage() + { + Span colPos = stackalloc int[3]; + Span colWidths = stackalloc int[3]; + + // 95 items, 10 per page = 10 pages, last page has 5 items + var layout = ListViewLayout.Create( + 0, 0, 300, 210, + 95, // total items + 9, // last page + 20, // row height + 10, // header height + "* * *", + colPos, colWidths); + + Assert.Equal(9, layout.CurrentPage); + Assert.Equal(90, layout.StartIndex); + Assert.Equal(5, layout.VisibleCount); // Only 5 items on last page + Assert.True(layout.CanGoBack); + Assert.False(layout.CanGoNext); + } + + [Fact] + public void TestListViewLayout_EmptyList() + { + Span colPos = stackalloc int[3]; + Span colWidths = stackalloc int[3]; + + var layout = ListViewLayout.Create( + 0, 0, 300, 210, + 0, // no items + 0, // current page + 20, // row height + 10, // header height + "* * *", + colPos, colWidths); + + Assert.Equal(0, layout.TotalItems); + Assert.Equal(1, layout.TotalPages); + Assert.Equal(0, layout.StartIndex); + Assert.Equal(0, layout.VisibleCount); + Assert.False(layout.CanGoBack); + Assert.False(layout.CanGoNext); + } + + [Fact] + public void TestListViewLayout_GetHeaderCell() + { + Span colPos = stackalloc int[3]; + Span colWidths = stackalloc int[3]; + + var layout = ListViewLayout.Create( + 10, 20, 300, 200, + 50, 0, 20, 30, + "100 100 100", + colPos, colWidths); + + var headerCell = layout.GetHeaderCell(1, colPos, colWidths); + + Assert.Equal(110, headerCell.X); // 10 (origin) + 100 (col 0 width) + Assert.Equal(20, headerCell.Y); // origin Y + Assert.Equal(100, headerCell.Width); + Assert.Equal(30, headerCell.Height); // header height + } + + [Fact] + public void TestListViewLayout_GetRowCell() + { + Span colPos = stackalloc int[3]; + Span colWidths = stackalloc int[3]; + + var layout = ListViewLayout.Create( + 0, 0, 300, 200, + 50, 0, 20, 30, + "100 100 100", + colPos, colWidths); + + var rowCell = layout.GetRowCell(2, 1, colPos, colWidths); + + Assert.Equal(100, rowCell.X); // column 1 position + Assert.Equal(70, rowCell.Y); // 0 (origin) + 30 (header) + 2*20 (rows) + Assert.Equal(100, rowCell.Width); + Assert.Equal(20, rowCell.Height); + } + + [Fact] + public void TestListViewLayout_ColumnSpec() + { + Span colPos = stackalloc int[12]; + Span colWidths = stackalloc int[12]; + + // The SpawnerControllerGump column spec + var layout = ListViewLayout.Create( + 0, 80, 1000, 620, + 100, 0, 45, 30, + "6* 8* 18* 6* 12* 6* 5* 5* 8* 8* 9* *", + colPos, colWidths); + + Assert.Equal(12, layout.ColumnCount); + + // First column: 6% of 1000 = 60 + Assert.Equal(0, colPos[0]); + Assert.Equal(60, colWidths[0]); + + // Second column: 8% = 80 + Assert.Equal(60, colPos[1]); + Assert.Equal(80, colWidths[1]); + } +} diff --git a/Projects/UOContent/Commands/HelpInfo.cs b/Projects/UOContent/Commands/HelpInfo.cs index f9686f37e..0b0328458 100644 --- a/Projects/UOContent/Commands/HelpInfo.cs +++ b/Projects/UOContent/Commands/HelpInfo.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Reflection; using Server.Commands.Generic; @@ -11,6 +12,7 @@ public static class HelpInfo { private static List _sortedHelpInfo; private static Dictionary _helpInfos; + private static Dictionary> _accessLevelCache; public static Dictionary HelpInfos { @@ -38,6 +40,28 @@ public static class HelpInfo } } + public static List GetCommandsForAccessLevel(AccessLevel accessLevel) + { + _accessLevelCache ??= []; + + if (_accessLevelCache.TryGetValue(accessLevel, out var cached)) + { + return cached; + } + + var list = new List(); + foreach (var c in SortedHelpInfo) + { + if (accessLevel >= c.AccessLevel) + { + list.Add(c); + } + } + + _accessLevelCache[accessLevel] = list; + return list; + } + public static void Configure() { CommandSystem.Register("HelpInfo", AccessLevel.Player, HelpInfo_OnCommand); @@ -74,7 +98,7 @@ public static class HelpInfo e.Mobile.SendMessage($"Command '{arg}' not found!"); } - e.Mobile.SendGump(new CommandListGump(0, e.Mobile, null)); + e.Mobile.SendGump(new CommandListGump(0, e.Mobile)); } public static void FillTable() @@ -88,23 +112,30 @@ public static class HelpInfo var mi = e.Handler.Method; - var usageAttr = mi.GetCustomAttribute(typeof(UsageAttribute), false) as UsageAttribute; + var usageAttr = mi.GetCustomAttribute(false); var usage = usageAttr?.Usage; - var descAttr = mi.GetCustomAttribute(typeof(DescriptionAttribute), false) as DescriptionAttribute; - var desc = descAttr?.Description - .ReplaceOrdinal("<", "(") - .ReplaceOrdinal(">", ")"); + var descAttr = mi.GetCustomAttribute(false); + var desc = descAttr?.Description.FixHtml(); if (e.Handler.Target != null && usage == null && desc == null) { continue; } - var aliasesAttr = mi.GetCustomAttribute(typeof(AliasesAttribute), false) as AliasesAttribute; + var aliasesAttr = mi.GetCustomAttribute(false); var aliases = aliasesAttr?.Aliases; - list.Add(new CommandInfo(e.AccessLevel, e.Command, aliases, usage ?? e.Command, null, desc ?? "No description available.")); + list.Add( + new CommandInfo( + e.AccessLevel, + e.Command, + aliases, + usage ?? e.Command, + null, + desc ?? "No description available." + ) + ); } for (var i = 0; i < TargetCommands.AllCommands.Count; ++i) @@ -128,10 +159,6 @@ public static class HelpInfo aliases[j] = cmds[j + 1]; } - desc = desc - .ReplaceOrdinal("<", "(") - .ReplaceOrdinal(">", ")") ?? "No description available."; - List modsList = []; foreach (var baseImpl in BaseCommandImplementor.Implementors) @@ -144,7 +171,16 @@ public static class HelpInfo var modifiers = modsList.ToArray(); - list.Add(new CommandInfo(command.AccessLevel, cmd, aliases, usage, modifiers, desc)); + list.Add( + new CommandInfo( + command.AccessLevel, + cmd, + aliases, + usage, + modifiers, + desc.FixHtml() ?? "No description available." + ) + ); } var commandImpls = BaseCommandImplementor.Implementors; @@ -165,10 +201,7 @@ public static class HelpInfo aliases[j] = cmds[j + 1]; } - desc = desc - .ReplaceOrdinal("<", ")") - .ReplaceOrdinal(">", ")"); - + desc = desc.FixHtml(); list.Add(new CommandInfo(command.AccessLevel, cmd, aliases, usage, null, desc)); } @@ -183,163 +216,196 @@ public static class HelpInfo } } - public class CommandListGump : BaseGridGump + public class CommandListGump : DynamicGump { private const int EntriesPerPage = 15; - private readonly List _list; - private readonly int _page; + // Layout constants matching BaseGridGump defaults + private const int ContentWidth = 360; // 20 + 320 + 20 + private const int MaxRows = 16; // 1 header + 15 entries + private const string ColumnSpec = "20 320 20"; - public CommandListGump(int page, Mobile from, List list) : base(30, 30) + private static readonly GridEntryStyle Style = GridEntryStyle.Default; + + private AccessLevel _accessLevel; + private int _page; + + public override bool Singleton => true; + + public CommandListGump(int page, Mobile from) : base(30, 30) { _page = page; + _accessLevel = from.AccessLevel; + } - if (list == null) - { - _list = []; + protected override void BuildLayout(ref DynamicGumpBuilder builder) + { + var list = GetCommandsForAccessLevel(_accessLevel); + var totalWidth = Style.GetTotalWidth(ContentWidth); + var totalHeight = Style.GetTotalHeight(MaxRows); - foreach (var c in SortedHelpInfo) - { - if (from.AccessLevel >= c.AccessLevel) - { - _list.Add(c); - } - } - } - else - { - _list = list; - } + // Calculate column positions with gaps + Span colPos = stackalloc int[3]; + Span colWidths = stackalloc int[3]; + GridCalculator.ComputeFromSpec( + ColumnSpec, + ContentWidth, + Style.ContentOriginX, + Style.OffsetSize, + colPos, + colWidths + ); - AddNewPage(); + // Background + builder.AddGridBackground(totalWidth, totalHeight, Style); + + var rowY = Style.ContentOriginY; + var totalPages = (list.Count + EntriesPerPage - 1) / EntriesPerPage; + + // Header row + var headerCell0 = new GridCell(colPos[0], rowY, colWidths[0], Style.EntryHeight); + var headerCell1 = new GridCell(colPos[1], rowY, colWidths[1], Style.EntryHeight); + var headerCell2 = new GridCell(colPos[2], rowY, colWidths[2], Style.EntryHeight); if (_page > 0) { - AddEntryButton(20, ArrowLeftID1, ArrowLeftID2, 1, ArrowLeftWidth, ArrowLeftHeight); + builder.AddEntryArrowLeft(headerCell0, Style, 1); } else { - AddEntryHeader(20); + builder.AddEntryHeader(headerCell0, Style); } - AddEntryHtml( - 320, - Center( - $"Page {_page + 1} of {(_list.Count + EntriesPerPage - 1) / EntriesPerPage}" - ) - ); + builder.AddImageTiled(headerCell1, Style.EntryGumpID); + builder.AddHtml(headerCell1, $"Page {_page + 1} of {totalPages}", Style.TextOffsetX, 0, align: TextAlignment.Center); - if ((_page + 1) * EntriesPerPage < _list.Count) + if ((_page + 1) * EntriesPerPage < list.Count) { - AddEntryButton(20, ArrowRightID1, ArrowRightID2, 2, ArrowRightWidth, ArrowRightHeight); + builder.AddEntryArrowRight(headerCell2, Style, 2); } else { - AddEntryHeader(20); + builder.AddEntryHeader(headerCell2, Style); } + // Data rows var last = (int)AccessLevel.Player - 1; + var dataContentWidth = colWidths[0] + Style.OffsetSize + colWidths[1]; - for (int i = _page * EntriesPerPage, line = 0; line < EntriesPerPage && i < _list.Count; ++i, ++line) + // Extract the StringBuilder to avoid repeated allocations + using var sb = ValueStringBuilder.Create(); + + for (int i = _page * EntriesPerPage, line = 0; line < EntriesPerPage && i < list.Count; ++i, ++line) { - var c = _list[i]; - if (from.AccessLevel >= c.AccessLevel) + var c = list[i]; + + // Access level separator + if ((int)c.AccessLevel != last) { - if ((int)c.AccessLevel != last) - { - AddNewLine(); - AddEntryHtml(20 + OffsetSize + 320, Color(c.AccessLevel.ToString(), 0xFF0000)); - AddEntryHeader(20); - line++; - } + rowY += Style.EntryHeight + Style.OffsetSize; + var sepContentCell = new GridCell(colPos[0], rowY, dataContentWidth, Style.EntryHeight); + var sepButtonCell = new GridCell(colPos[2], rowY, colWidths[2], Style.EntryHeight); - last = (int)c.AccessLevel; - - AddNewLine(); - string name; - if (c.Aliases?.Length > 0) - { - using var sb = ValueStringBuilder.Create(); - sb.Append($"{c.Name} ("); - for (var j = 0; j < c.Aliases.Length; ++j) - { - if (j != 0) - { - sb.Append(", "); - } - - sb.Append(c.Aliases[j]); - } - - sb.Append(")"); - name = sb.ToString(); - } - else - { - name = c.Name; - } - - AddEntryHtml(20 + OffsetSize + 320, name); - AddEntryButton(20, ArrowRightID1, ArrowRightID2, 3 + i, ArrowRightWidth, ArrowRightHeight); + builder.AddImageTiled(sepContentCell, Style.EntryGumpID); + builder.AddHtml(sepContentCell, $"{c.AccessLevel}", Style.TextOffsetX, 0, GumpTextColors.BrightRed); + builder.AddEntryHeader(sepButtonCell, Style); + line++; } - } - FinishPage(); + last = (int)c.AccessLevel; + + // Entry row + rowY += Style.EntryHeight + Style.OffsetSize; + var contentCell = new GridCell(colPos[0], rowY, dataContentWidth, Style.EntryHeight); + var buttonCell = new GridCell(colPos[2], rowY, colWidths[2], Style.EntryHeight); + + builder.AddImageTiled(contentCell, Style.EntryGumpID); + + if (c.Aliases?.Length > 0) + { + sb.Append($"{c.Name} ("); + for (var j = 0; j < c.Aliases.Length; ++j) + { + if (j != 0) + { + sb.Append(", "); + } + sb.Append(c.Aliases[j]); + } + sb.Append(")"); + builder.AddHtml(contentCell, sb.RawChars[..sb.Length], Style.TextOffsetX, 0); + + // Reset the SB for the next loop. + sb.Reset(); + } + else + { + builder.AddHtml(contentCell, c.Name, Style.TextOffsetX, 0); + } + + builder.AddEntryArrowRight(buttonCell, Style, 3 + i); + } } public override void OnResponse(NetState sender, in RelayInfo info) { var m = sender.Mobile; var gumps = m.GetGumps(); + var list = GetCommandsForAccessLevel(_accessLevel); switch (info.ButtonID) { case 0: { gumps.Close(); - break; + return; } case 1: { - if (_page > 0) + if (_page <= 0) { - gumps.Send(new CommandListGump(_page - 1, m, _list)); + return; } + _page--; break; } case 2: { - if ((_page + 1) * EntriesPerPage < SortedHelpInfo.Count) + if ((_page + 1) * EntriesPerPage >= list.Count) { - gumps.Send(new CommandListGump(_page + 1, m, _list)); + return; } + _page++; break; } default: { var v = info.ButtonID - 3; - if (v >= 0 && v < _list.Count) + if (v < 0 || v >= list.Count) { - var c = _list[v]; + return; + } - if (m.AccessLevel >= c.AccessLevel) - { - gumps.Send(new CommandInfoGump(c)); - gumps.Send(new CommandListGump(_page, m, _list)); - } - else - { - m.SendMessage("You no longer have access to that command."); - gumps.Send(new CommandListGump(_page, m, null)); - } + var c = list[v]; + + if (m.AccessLevel >= c.AccessLevel) + { + gumps.Send(new CommandInfoGump(c)); + } + else + { + m.SendMessage("You no longer have access to that command."); + _accessLevel = m.AccessLevel; } break; } } + + m.SendGump(this); } } @@ -366,10 +432,10 @@ public static class HelpInfo using var sb = ValueStringBuilder.Create(); sb.Append("Usage: "); - var usage = _info.Usage - .ReplaceOrdinal("<", "(") - .ReplaceOrdinal(">", ")"); - sb.Append(usage); + // Extends the StringBuilder and returns the Span for custom writing + var usage = sb.AppendSpan(_info.Usage.Length); + _info.Usage.CopyTo(usage); + usage.FixHtml(); sb.Append("
"); var aliases = _info.Aliases; @@ -392,7 +458,7 @@ public static class HelpInfo } sb.Append("AccessLevel: "); - sb.Append(_info.AccessLevel.ToString()); + sb.Append($"{_info.AccessLevel}"); sb.Append("
"); if (_info.Modifiers?.Length > 0) @@ -407,9 +473,11 @@ public static class HelpInfo sb.Append(_info.Modifiers[i]); } + sb.Append("
"); sb.Append("
"); } + sb.Append(_info.Description); builder.AddHtml(10, 40, _width - 20, _height - 80, sb.AsSpan(true), background: false, scrollbar: true); diff --git a/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs b/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs index 62862e0db..169f2b54a 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs @@ -1,7 +1,22 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: SpawnerControllerGump.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + using System; using Server.Collections; -using Server.Network; using Server.Engines.Spawners; +using Server.Network; namespace Server.Gumps; @@ -12,24 +27,35 @@ public enum SpawnSearchType Props, Name } + public class SpawnSearch { public SpawnSearchType Type { get; set; } = SpawnSearchType.Creature; public string SearchPattern { get; set; } = ""; } -public class SpawnerControllerGump : GumpGrid +public class SpawnerControllerGump : DynamicGump { - private BaseSpawner _copy; - private int _page; + // Grid dimensions + private const int GumpWidth = 1000; + private const int GumpHeight = 800; + private const int RowHeight = 45; + private const int HeaderHeight = 28; private const int EntryCount = 5; - private Mobile _mobile; - private SpawnSearch _search; - private int _lineCount; - private BaseSpawner[] _spawners = Array.Empty(); - private Grid _main; private const int TypeCount = 15; + // Column spec for the 12-column list + private const string ColumnSpec = "6* 8* 18* 6* 12* 6* 5* 5* 8* 8* 9* *"; + // Row spec for main grid: search (10%), list (*), footer (100px) + private const string MainRowSpec = "10* * 100"; + + private readonly Mobile _mobile; + private int _page; + private readonly SpawnSearch _search; + private BaseSpawner _copy; + private BaseSpawner[] _spawners = Array.Empty(); + private int _lineCount; + public static int GetButtonID(int type, int index) => 1 + type + index * TypeCount; public static void Configure() @@ -45,97 +71,339 @@ public class SpawnerControllerGump : GumpGrid e.Mobile.SendGump(new SpawnerControllerGump(e.Mobile)); } - public void AddBlackAlpha(int x, int y, int width, int height) + public SpawnerControllerGump(Mobile mobile, int page = 0, BaseSpawner copy = null, SpawnSearch search = null) + : base(20, 30) { - AddImageTiled(x, y, width, height, 2624); - AddAlphaRegion(x, y, width, height); + _mobile = mobile; + _page = page; + _copy = copy; + _search = search ?? new SpawnSearch(); + + SearchSpawner(); } - public void DrawBorder(ListView list) + protected override void BuildLayout(ref DynamicGumpBuilder builder) { - var row1Y = _main.Rows[1].Y; - AddImageTiled(0, row1Y, _main.Width, 3, 9357); - AddAlphaRegion(0, row1Y, _main.Width, 3); + builder.AddPage(); - AddImageTiled(0, row1Y + 21, _main.Width, 3, 9357); - AddAlphaRegion(0, row1Y + 21, _main.Width, 3); + // Background + builder.AddBackground(0, 0, GumpWidth, GumpHeight, 5054); + builder.AddImageTiled(0, 0, GumpWidth, GumpHeight, 2624); + builder.AddAlphaRegion(0, 0, GumpWidth, GumpHeight); - for (var i = 0; i < list.ColsCount - 1; i++) + // Calculate main grid: 1 column x 3 rows + Span mainRowPos = stackalloc int[3]; + Span mainRowHeights = stackalloc int[3]; + + Span mainRowSpecs = stackalloc GridSizeSpec[3]; + GridSizeSpec.ParseAll(MainRowSpec, mainRowSpecs); + GridCalculator.ComputeTrackSizes(mainRowSpecs, GumpHeight, 0, mainRowPos, mainRowHeights); + + // Calculate list view layout + Span listColPos = stackalloc int[12]; + Span listColWidths = stackalloc int[12]; + + var listView = ListViewLayout.Create( + 0, + mainRowPos[1], + GumpWidth, + mainRowHeights[1] - 7, // marginY adjustment + _spawners.Length, + _page, + RowHeight, + HeaderHeight, + ColumnSpec, + listColPos, + listColWidths + ); + + _lineCount = listView.ItemsPerPage; + + // Draw borders + var row1Y = mainRowPos[1]; + var row2Y = mainRowPos[2]; + builder.AddImageTiled(0, row1Y, GumpWidth, 3, 9357); + builder.AddAlphaRegion(0, row1Y, GumpWidth, 3); + builder.AddImageTiled(0, row1Y + 21, GumpWidth, 3, 9357); + builder.AddAlphaRegion(0, row1Y + 21, GumpWidth, 3); + + for (var i = 0; i < listView.ColumnCount - 1; i++) { - var x = list.Header.Cols[i].HEnd - 8; - var y = list.Header.Y + 10; - var height = 20; - AddImageTiled(x, y, 3, height, 9355); - AddAlphaRegion(x, y, 3, height); + var x = listColPos[i] + listColWidths[i] - 8; + var y = mainRowPos[1] + 3; + builder.AddImageTiled(x, y, 3, 20, 9355); + builder.AddAlphaRegion(x, y, 3, 20); } - AddImageTiled(0, _main.Rows[2].Y - 11, _main.Width, 6, 9357); - AddAlphaRegion(0, _main.Rows[2].Y - 11, _main.Width, 6); - AddImageTiled(0, _main.Rows[2].Y + 55, _main.Width, 6, 9357); - AddAlphaRegion(0, _main.Rows[2].Y + 55, _main.Width, 6); + builder.AddImageTiled(0, row2Y - 11, GumpWidth, 6, 9357); + builder.AddAlphaRegion(0, row2Y - 11, GumpWidth, 6); + builder.AddImageTiled(0, row2Y + 55, GumpWidth, 6, 9357); + builder.AddAlphaRegion(0, row2Y + 55, GumpWidth, 6); + + // Draw search area + DrawSearch(ref builder, mainRowPos[0], mainRowHeights[0]); + + // Draw column headers + ReadOnlySpan headers = + [ + "Copy", "Paste", "Name/Serial", "Map", "Coords", + "Entry", "Walk", "Home", "Min Delay", "Max Delay", + "Next Spawn", "Actions" + ]; + + for (var i = 0; i < listView.ColumnCount && i < headers.Length; i++) + { + var cell = listView.GetHeaderCell(i, listColPos, listColWidths); + var offsetX = i >= 4 ? -4 : 0; + builder.AddHtml( + cell.X + offsetX, + cell.Y + 3, + cell.Width, + 30, + headers[i], + GumpTextColors.Gold, + 4, + align: TextAlignment.Center + ); + } + + // Draw spawner rows + const int vCenter = 10; + for (var i = 0; i < listView.VisibleCount; i++) + { + var dataIndex = listView.GetDataIndex(i); + var spawner = _spawners[dataIndex]; + var rowY = listView.GetRowY(i); + + // Column 0: Copy button + var col0X = listColPos[0]; + var col0Width = listColWidths[0]; + builder.AddButton(col0X + 15, rowY, 4011, 4012, GetButtonID(2, dataIndex)); + builder.AddHtml(col0X, rowY + 21, col0Width, 30, "copy", GumpTextColors.White, 3, align: TextAlignment.Center); + + // Column 1: Paste buttons + var col1X = listColPos[1]; + var col1Width = listColWidths[1]; + var col1HCenter = col1X + col1Width / 2; + builder.AddButton(col1X + 2, rowY, 4029, 4031, GetButtonID(3, dataIndex)); + builder.AddHtml(col1X + 3, rowY + 21, col1Width, 30, "props", GumpTextColors.White, 3); + + builder.AddButton(col1HCenter - 2, rowY, 4029, 4031, GetButtonID(4, dataIndex)); + builder.AddHtml(col1HCenter - 3, rowY + 21, 55, 30, "entry", GumpTextColors.White, 3); + + // Column 2: Name/Serial + var col2X = listColPos[2]; + var col2Width = listColWidths[2]; + var entryIndex = i * EntryCount; + + builder.AddImageTiled(col2X + 6, rowY + 13, col2Width - 8, 1, 9357); + builder.AddTextEntry(col2X + 8, rowY - 3, col2Width - 8, 80, GumpHues.White, entryIndex, spawner.Name); + builder.AddButton(col2X + 8, rowY + 19, 5837, 5838, GetButtonID(6, dataIndex)); + builder.AddHtml(col2X + 54, rowY + 19, col2Width, 30, $"{spawner.Serial}", GumpTextColors.White, 4); + + // Column 3: Map + var col3X = listColPos[3]; + var col3Width = listColWidths[3]; + builder.AddHtml( + col3X, + rowY + vCenter, + col3Width, + 30, + $"{spawner.Map}", + GumpTextColors.White, + 4, + align: TextAlignment.Center + ); + + // Column 4: Coords with teleport button + var col4X = listColPos[4]; + var col4Width = listColWidths[4]; + builder.AddButton(col4X + 3, rowY + vCenter, 2062, 2062, GetButtonID(5, dataIndex)); + builder.AddImage(col4X + 3, rowY + vCenter, 2062, 936); + builder.AddHtml( + col4X + 12, + rowY + vCenter, + col4Width, + 30, + $"X {spawner.Location.X} Y {spawner.Location.Y}", + GumpTextColors.White, + 4 + ); + + // Column 5: Entry count with button + var col5X = listColPos[5]; + var col5Width = listColWidths[5]; + builder.AddButton(col5X + 6, rowY + vCenter - 4, 0x2635, 0x2635, GetButtonID(7, dataIndex)); + builder.AddImage(col5X + 6, rowY + vCenter - 7, 0x2635, 827); + builder.AddHtml( + col5X, + rowY + vCenter, + col5Width, + 30, + $"{spawner.Entries?.Count ?? 0}", + GumpTextColors.White, + 4, + align: TextAlignment.Center + ); + + // Column 6: Walk Range + var col6X = listColPos[6]; + var col6Width = listColWidths[6]; + builder.AddImageTiled(col6X + 8, rowY + 24, col6Width / 2, 1, 9357); + builder.AddTextEntry( + col6X + 12, + rowY + 8, + col6Width, + 80, + GumpHues.White, + entryIndex + 1, + $"{spawner.WalkingRange}" + ); + + // Column 7: Home Range + var col7X = listColPos[7]; + var col7Width = listColWidths[7]; + if (spawner.IsHomeRangeStyle) + { + builder.AddImageTiled(col7X + 8, rowY + 24, col7Width / 2, 1, 9357); + builder.AddTextEntry(col7X + 12, rowY + 8, col7Width, 80, GumpHues.White, entryIndex + 2, $"{spawner.HomeRange}"); + } + else + { + builder.AddHtml(col7X - 6, rowY + 8, col7Width, 30, "Custom", GumpTextColors.Yellow, 4, align: TextAlignment.Center); + } + + // Column 8: Min Delay + var col8X = listColPos[8]; + var col8Width = listColWidths[8]; + builder.AddImageTiled(col8X + 6, rowY + 24, col8Width - 20, 1, 9357); + builder.AddTextEntry(col8X + 8, rowY + 8, col8Width, 80, GumpHues.White, entryIndex + 3, $"{spawner.MinDelay}"); + + // Column 9: Max Delay + var col9X = listColPos[9]; + var col9Width = listColWidths[9]; + builder.AddImageTiled(col9X + 6, rowY + 24, col9Width - 20, 1, 9357); + builder.AddTextEntry(col9X + 8, rowY + 8, col9Width, 80, GumpHues.White, entryIndex + 4, $"{spawner.MaxDelay}"); + + // Column 10: Next Spawn + var col10X = listColPos[10]; + var col10Width = listColWidths[10]; + builder.AddHtml( + col10X, + rowY + vCenter, + col10Width, + 30, + $@"{spawner.NextSpawn:hh\:mm\:ss}", + GumpTextColors.White, + 4, + align: TextAlignment.Center + ); + + // Column 11: Actions + var col11X = listColPos[11]; + builder.AddButton(col11X, rowY, 4023, 4025, GetButtonID(8, dataIndex)); + builder.AddHtml(col11X + 4, rowY + 21, 55, 30, "save", GumpTextColors.White, 3); + + builder.AddButton(col11X + RowHeight, rowY, 4017, 4018, GetButtonID(9, dataIndex)); + builder.AddHtml(col11X + RowHeight, rowY + 21, 55, 30, "delete", GumpTextColors.White, 3); + + // Row separator + builder.AddImageTiled(0, rowY + RowHeight, GumpWidth, 1, 9357); + builder.AddAlphaRegion(0, rowY + RowHeight, GumpWidth, 1); + } + + // Draw footer + const int col0HCenter = GumpWidth / 2; + var row2VCenter = row2Y + mainRowHeights[2] / 2; + + if (listView.CanGoNext) + { + builder.AddButton(col0HCenter + 60, row2VCenter + 16, 4005, 4006, GetButtonID(10, 0)); + } + + if (listView.CanGoBack) + { + builder.AddButton(col0HCenter - 60, row2VCenter + 16, 4014, 4015, GetButtonID(11, 0)); + } + + builder.AddButton(15, row2Y, 4029, 4031, GetButtonID(1, 5)); + builder.AddHtml(55, row2Y + 4, GumpWidth, 20, $"Paste props for {_spawners.Length} spawners", GumpTextColors.Orange, 4); + + builder.AddButton(15, row2Y + 28, 4029, 4031, GetButtonID(1, 6)); + builder.AddHtml(55, row2Y + 32, GumpWidth, 20, $"Paste entry for {_spawners.Length} spawners", GumpTextColors.Orange, 4); + + builder.AddButton(275, row2Y, 4029, 4031, GetButtonID(1, 7)); + builder.AddHtml(315, row2Y + 4, GumpWidth, 20, "Paste copy to the ground", GumpTextColors.Yellow, 4); + + builder.AddHtml( + 16, + row2VCenter + 17, + GumpWidth, + 20, + $"Pages {listView.CurrentPage + 1}/{listView.TotalPages}", + GumpTextColors.White, + 4 + ); + builder.AddHtml(120, row2VCenter + 17, GumpWidth, 20, $"Total Spawners {_spawners.Length}", GumpTextColors.White, 4); } - private static string ExtractCoords(Point3D cord) => $"X {cord.X} Y {cord.Y}"; - - public void DrawSearch() + private void DrawSearch(ref DynamicGumpBuilder builder, int row0Y, int searchHeight) { + // Create 2-column subgrid for search area + Span searchColPos = stackalloc int[2]; + Span searchColWidths = stackalloc int[2]; + GridCalculator.ComputeUniformTrackSizes(2, GumpWidth, 0, searchColPos, searchColWidths); + + var col0X = searchColPos[0]; + var col1X = searchColPos[1]; + var col1HCenter = col1X + searchColWidths[1] / 2; + var row0VCenter = row0Y + searchHeight / 2; + const int deltaX = 140; - var search = SubGrid("search", _main.Name, 0, 0, 2, 1); - var col0X = search.Columns[0].X; - var col1X = search.Columns[1].X; - var col1HCenter = search.Columns[1].HCenter; - var row0Y = search.Rows[0].Y; - var row0VCenter = search.Rows[0].VCenter; + // Labels + builder.AddHtml(col0X, row0Y + 8, searchColWidths[0], 30, "Search type", GumpTextColors.Gold, 4, align: TextAlignment.Center); + builder.AddHtml(col1X, row0Y + 8, searchColWidths[1], 30, "Search Match", GumpTextColors.Gold, 4, align: TextAlignment.Center); - AddLabelHtml(col0X, row0Y + 8, col1X, 30, "Search type", GridColors.Gold); - AddLabelHtml(col1X, row0Y + 8, col1X, 30, "Search Match", GridColors.Gold); - - AddImageTiled(0, row0Y + 30, _main.Width, 3, 9357); - AddAlphaRegion(0, row0Y + 30, _main.Width, 3); - - AddImageTiled(col1X, row0Y, 3, search.Height, 9355); - AddAlphaRegion(col1X, row0Y, 3, search.Height); + // Separators + builder.AddImageTiled(0, row0Y + 30, GumpWidth, 3, 9357); + builder.AddAlphaRegion(0, row0Y + 30, GumpWidth, 3); + builder.AddImageTiled(col1X, row0Y, 3, searchHeight, 9355); + builder.AddAlphaRegion(col1X, row0Y, 3, searchHeight); + // Search type buttons var creature = _search.Type == SpawnSearchType.Creature ? 9021 : 9020; - var cords = _search.Type == SpawnSearchType.Coords ? 9021 : 9020; + var coords = _search.Type == SpawnSearchType.Coords ? 9021 : 9020; var props = _search.Type == SpawnSearchType.Props ? 9021 : 9020; - var args = _search.Type == SpawnSearchType.Name ? 9021 : 9020; + var name = _search.Type == SpawnSearchType.Name ? 9021 : 9020; - AddButton(col0X + 10, row0Y + 45, creature, creature, GetButtonID(1, 0)); - AddLabelHtml(col0X + 15, row0Y + 48, 100, 30, "Creature", GridColors.White); + builder.AddButton(col0X + 10, row0Y + 45, creature, creature, GetButtonID(1, 0)); + builder.AddHtml(col0X + 15, row0Y + 48, 100, 30, "Creature", GumpTextColors.White, 4, align: TextAlignment.Center); + builder.AddButton(col0X + 10 + deltaX - 40, row0Y + 45, coords, coords, GetButtonID(1, 1)); + builder.AddHtml(col0X + 40 + deltaX - 40, row0Y + 48, 150, 30, "Range", GumpTextColors.White, 4); - AddButton(col0X + 10 + deltaX - 40, row0Y + 45, cords, cords, GetButtonID(1, 1)); - AddLabelHtml(col0X + 40 + deltaX - 40, row0Y + 48, 150, 30, "Range", GridColors.White, 4, false); + builder.AddButton(col0X + 10 + deltaX * 2 - 100, row0Y + 45, props, props, GetButtonID(1, 2)); + builder.AddHtml(col0X + 40 + deltaX * 2 - 100, row0Y + 48, 150, 30, "Creature Props", GumpTextColors.White, 4); - AddButton(col0X + 10 + deltaX * 2 - 100, row0Y + 45, props, props, GetButtonID(1, 2)); - AddLabelHtml(col0X + 40 + deltaX * 2 - 100, row0Y + 48, 150, 30, "Creature Props", GridColors.White, 4, false); + builder.AddButton(col0X + 10 + deltaX * 3 - 100, row0Y + 45, name, name, GetButtonID(1, 3)); + builder.AddHtml(col0X + 40 + deltaX * 3 - 100, row0Y + 48, 150, 30, "Spawner Name", GumpTextColors.White, 4); - AddButton(col0X + 10 + deltaX * 3 - 100, row0Y + 45, args, args, GetButtonID(1, 3)); - AddLabelHtml(col0X + 40 + deltaX * 3 - 100, row0Y + 48, 150, 30, "Spawner Name", GridColors.White, 4, false); + // Search text entry + builder.AddImageTiled(col1HCenter - 100, row0VCenter + 16, 200, 1, 9357); + builder.AddTextEntry(col1HCenter - 100, row0VCenter, 200, 20, GumpHues.White, 0xFFFF, _search.SearchPattern); + builder.AddButton(col1HCenter + 120, row0VCenter, 4023, 4024, GetButtonID(1, 4)); - AddImageTiled(col1HCenter - 100, row0VCenter + 16, 200, 1, 9357); - AddTextEntry(col1HCenter - 100, row0VCenter, 200, 20, (int)GridHues.White, 0xFFFF, _search.SearchPattern, GetButtonID(1, 4)); - AddButton(col1HCenter + 120, row0VCenter, 4023, 4024, GetButtonID(1, 4)); - - var type = (int)_search.Type; - if (type == 0) + // Search type description + var description = (int)_search.Type switch { - AddLabelHtml(col1X, row0VCenter + 20, search.Columns[1].Width, 30, "Search by creature name", GridColors.White); - } - else if (type == 1) - { - AddLabelHtml(col1X, row0VCenter + 20, search.Columns[1].Width, 30, "Search by range (number)", GridColors.White); - } - else if (type == 2) - { - AddLabelHtml(col1X, row0VCenter + 20, search.Columns[1].Width, 30, "Search by entry property field", GridColors.White); - } - else if (type == 3) - { - AddLabelHtml(col1X, row0VCenter + 20, search.Columns[1].Width, 30, "Search by spawner name", GridColors.White); - } + 0 => "Search by creature name", + 1 => "Search by range (number)", + 2 => "Search by entry property field", + 3 => "Search by spawner name", + _ => "" + }; + builder.AddHtml(col1X, row0VCenter + 20, searchColWidths[1], 30, description, GumpTextColors.White, 4, align: TextAlignment.Center); } private void SearchSpawner() @@ -147,7 +415,7 @@ public class SpawnerControllerGump : GumpGrid using var queue = PooledRefQueue.Create(); - if (!(_search.Type == SpawnSearchType.Coords && int.TryParse(_search.SearchPattern, out var range))) + if (_search.Type != SpawnSearchType.Coords || !int.TryParse(_search.SearchPattern, out var range)) { range = -1; } @@ -192,8 +460,9 @@ public class SpawnerControllerGump : GumpGrid private static bool SearchSpawnerProperties(BaseSpawner spawner, string searchPattern) { - foreach (var entry in spawner.Entries) + for (var i = 0; i < spawner.Entries.Count; i++) { + var entry = spawner.Entries[i]; if (entry.Properties?.InsensitiveContains(searchPattern) == true) { return true; @@ -203,168 +472,7 @@ public class SpawnerControllerGump : GumpGrid return false; } - private void DrawFooter(ListView list) - { - if (list.CanNext) - { - AddButton(_main.Columns[0].HCenter + 60, _main.Rows[2].VCenter + 16, 4005, 4006, GetButtonID(10, 0)); - } - - if (list.CanBack) - { - AddButton(_main.Columns[0].HCenter + -60, _main.Rows[2].VCenter + 16, 4014, 4015, GetButtonID(11, 0)); - } - - var col0X = _main.Columns[0].X; - var col0Width = _main.Columns[0].Width; - var row2Y = _main.Rows[2].Y; - - //past to all spawners - AddButton(col0X + 15, row2Y, 4029, 4031, GetButtonID(1, 5)); - AddLabelHtml(col0X + 55, row2Y + 4, col0Width, 20, $"Paste props for {_spawners.Length} spawners", GridColors.Orange, 4, false); - - //paste target - AddButton(col0X + 15, row2Y + 28, 4029, 4031, GetButtonID(1, 6)); - AddLabelHtml(col0X + 55, row2Y + 32, col0Width, 20, $"Paste entry for {_spawners.Length} spawners", GridColors.Orange, 4, false); - - //paste ground - AddButton(col0X + 15 + 260, row2Y, 4029, 4031, GetButtonID(1, 7)); - AddLabelHtml(col0X + 55 + 260, row2Y + 4, col0Width, 20, "Paste copy to the ground", GridColors.Yellow, 4, false); - - AddLabelHtml(col0X + 16, _main.Rows[2].VCenter + 17, col0Width, 20, $"Pages {list.Page + 1}/{list.TotalPages + 1}", GridColors.White); - AddLabelHtml(col0X + 20, _main.Rows[2].VCenter + 17, col0Width, 20, $"Total Spawners {_spawners.Length}", GridColors.White, 4, false); - } - - - public void DrawSpawner(ListView list) - { - for (var i = 0; i < list.Items.Length; i++) - { - const int vCenter = 15; - const int perItem = 45; - - var spawner = _spawners[list.Items[i].Index]; - var item = list.Items[i]; - - AddButton(item.Cols[0].X + 15, list.Items[i].Y + 5, 4011, 4012, GetButtonID(2, item.Index)); - AddLabelHtml(item.Cols[0].X, list.Items[i].Y + 26, item.Cols[0].Width, 30, "copy", GridColors.White, 3); - - AddButton(item.Cols[1].X + 2, list.Items[i].Y + 5, 4029, 4031, GetButtonID(3, item.Index)); - AddLabelHtml(item.Cols[1].X + 3, list.Items[i].Y + 26, item.Cols[1].Width, 30, "props", GridColors.White, 3, false); - - AddButton(item.Cols[1].HCenter - 2, list.Items[i].Y + 5, 4029, 4031, GetButtonID(4, item.Index)); - AddLabelHtml(item.Cols[1].HCenter - 3, list.Items[i].Y + 26, 55, 30, "entry", GridColors.White, 3, false); - - //props - AddLabelHtml(item.Cols[2].X, list.Items[i].Y + vCenter + 10, list.Header.Cols[2].Width, 30, spawner.Serial.ToString(), GridColors.White); - - //map - AddLabelHtml(item.Cols[3].X, list.Items[i].Y + vCenter, list.Header.Cols[3].Width, 30, spawner.Map.ToString(), GridColors.White); - - //teleport button - AddButton(item.Cols[4].X - 5, list.Items[i].Y + vCenter, 2062, 2062, GetButtonID(5, item.Index)); - AddImage(item.Cols[4].X - 5, list.Items[i].Y + vCenter, 2062, 936); - AddLabelHtml(item.Cols[4].X - 7, list.Items[i].Y + vCenter, list.Header.Cols[4].Width, 30, ExtractCoords(spawner.Location), GridColors.White); - - //open entry button - AddButton(item.Cols[5].X + 6, list.Items[i].Y + vCenter - 4, 0x2635, 0x2635, GetButtonID(7, item.Index)); - AddImage(item.Cols[5].X + 6, list.Items[i].Y + vCenter - 7, 0x2635, 827); - AddLabelHtml(item.Cols[5].X, list.Items[i].Y + vCenter, list.Header.Cols[5].Width, 30, spawner.Entries?.Count.ToString(), GridColors.White); - - - //entry - var entry = i * EntryCount; - - AddImageTiled(item.Cols[2].X - 2, list.Items[i].Y + 18, item.Cols[2].Width - 8, 1, 9357); - AddTextEntry(item.Cols[2].X, list.Items[i].Y + 2, list.Header.Cols[2].Width - 8, 80, (int)GridHues.White, entry, spawner.Name, 20); - AddButton(item.Cols[2].X, list.Items[i].Y + vCenter + 9, 5837, 5838, GetButtonID(6, item.Index)); - - AddImageTiled(item.Cols[6].X + 8, list.Items[i].Y + 29, item.Cols[6].Width / 2, 1, 9357); - AddTextEntry(item.Cols[6].X + 12, list.Items[i].Y + 13, list.Header.Cols[6].Width, 80, (int)GridHues.White, entry + 1, spawner.WalkingRange.ToString(), 2); - - // Show HomeRange if bounds are HomeRange-style, otherwise show "Custom" - if (spawner.IsHomeRangeStyle) - { - AddImageTiled(item.Cols[7].X + 8, list.Items[i].Y + 29, item.Cols[7].Width / 2, 1, 9357); - AddTextEntry(item.Cols[7].X + 12, list.Items[i].Y + 13, list.Header.Cols[7].Width, 80, (int)GridHues.White, entry + 2, spawner.HomeRange.ToString(), 2); - } - else - { - AddLabelHtml(item.Cols[7].X - 6, list.Items[i].Y + 13, list.Header.Cols[7].Width, 30, "Custom", GridColors.Yellow); - } - - AddImageTiled(item.Cols[8].X + 6, list.Items[i].Y + 29, item.Cols[8].Width - 20, 1, 9357); - AddTextEntry(item.Cols[8].X + 8, list.Items[i].Y + 13, list.Header.Cols[8].Width, 80, (int)GridHues.White, entry + 3, spawner.MinDelay.ToString(), 8); - - AddImageTiled(item.Cols[9].X + 6, list.Items[i].Y + 29, item.Cols[9].Width - 20, 1, 9357); - AddTextEntry(item.Cols[9].X + 8, list.Items[i].Y + 13, list.Header.Cols[9].Width, 80, (int)GridHues.White, entry + 4, spawner.MaxDelay.ToString(), 8); - //end entry - - AddLabelHtml(item.Cols[10].X, list.Items[i].Y + vCenter, list.Header.Cols[10].Width, 30, spawner.NextSpawn.ToString(@"hh\:mm\:ss"), GridColors.White); - - AddButton(item.Cols[11].X, list.Items[i].Y + 5, 4023, 4025, GetButtonID(8, item.Index)); - AddLabelHtml(item.Cols[11].X + 4, list.Items[i].Y + 26, 55, 30, "save", GridColors.White, 3, false); - - AddButton(item.Cols[11].X + perItem, list.Items[i].Y + 5, 4017, 4018, GetButtonID(9, item.Index)); - AddLabelHtml(item.Cols[11].X + perItem, list.Items[i].Y + 26, 55, 30, "delete", GridColors.White, 3, false); - - AddImageTiled(0, list.Items[i].Y + list.ColHeight, _main.Width, 1, 9357); - AddAlphaRegion(0, list.Items[i].Y + list.ColHeight, _main.Width, 1); - } - } - - public void DrawColumnName(ListView list) - { - AddLabelHtml(list.Header.Cols[0].X, list.Header.Y + 10, list.Header.Cols[0].Width, 30, "Copy", GridColors.Gold); - AddLabelHtml(list.Header.Cols[1].X, list.Header.Y + 10, list.Header.Cols[1].Width, 30, "Paste", GridColors.Gold); - AddLabelHtml(list.Header.Cols[2].X, list.Header.Y + 10, list.Header.Cols[2].Width, 30, "Name/Serial", GridColors.Gold); - AddLabelHtml(list.Header.Cols[3].X, list.Header.Y + 10, list.Header.Cols[3].Width, 30, "Map", GridColors.Gold); - AddLabelHtml(list.Header.Cols[4].X - 4, list.Header.Y + 10, list.Header.Cols[4].Width, 30, "Coords", GridColors.Gold); - AddLabelHtml(list.Header.Cols[5].X - 4, list.Header.Y + 10, list.Header.Cols[5].Width, 30, "Entry", GridColors.Gold); - AddLabelHtml(list.Header.Cols[6].X - 4, list.Header.Y + 10, list.Header.Cols[6].Width, 30, "Walk", GridColors.Gold); - AddLabelHtml(list.Header.Cols[7].X - 4, list.Header.Y + 10, list.Header.Cols[7].Width, 30, "Home", GridColors.Gold); - AddLabelHtml(list.Header.Cols[8].X - 4, list.Header.Y + 10, list.Header.Cols[8].Width, 30, "Min Delay", GridColors.Gold); - AddLabelHtml(list.Header.Cols[9].X - 4, list.Header.Y + 10, list.Header.Cols[9].Width, 30, "Max Delay", GridColors.Gold); - AddLabelHtml(list.Header.Cols[10].X - 4, list.Header.Y + 10, list.Header.Cols[10].Width, 30, "Next Spawn", GridColors.Gold); - AddLabelHtml(list.Header.Cols[11].X, list.Header.Y + 10, list.Header.Cols[11].Width, 30, "Actions", GridColors.Gold); - } - - public SpawnerControllerGump(Mobile mobile, int page = 0, BaseSpawner copy = null, SpawnSearch search = null) : base(20, 30) - { - _main = Grid("main", 1000, 800, 1, 3, rowSize: "10* * 100"); - - AddBackground(0, 0, 1000, 800, 5054); - AddBlackAlpha(0, 0, 1000, 800); - - _mobile = mobile; - _page = page; - _copy = copy; - _search = search ?? new SpawnSearch(); - - SearchSpawner(); - - var _list = AddListView( - _main.Name, - 0, - 1, - _spawners.Length, - page, - 45, - 12, - colSize: "6* 8* 18* 6* 12* 6* 5* 5* 8* 8* 9* *", - headerHeight: 30, - marginY: -7 - ); - - _lineCount = _list.LineCount; - DrawBorder(_list); - DrawSearch(); - DrawSpawner(_list); - DrawColumnName(_list); - DrawFooter(_list); - - } - public void CopyProperty(BaseSpawner spawner, BaseSpawner target) + public static void CopyProperty(BaseSpawner spawner, BaseSpawner target) { target.Name = spawner.Name; target.MinDelay = spawner.MinDelay; @@ -375,7 +483,8 @@ public class SpawnerControllerGump : GumpGrid target.Group = spawner.Group; target.Count = spawner.Count; } - public void CopyEntry(BaseSpawner spawner, BaseSpawner target) + + public static void CopyEntry(BaseSpawner spawner, BaseSpawner target) { if (spawner.Entries?.Count > 0) { @@ -388,41 +497,41 @@ public class SpawnerControllerGump : GumpGrid targetEntry.Properties = item.Properties; targetEntry.Parameters = item.Parameters; } - } } + public void FullCopy(BaseSpawner spawner, BaseSpawner target) { CopyProperty(spawner, target); CopyEntry(spawner, target); } - public void Refresh(bool update = true) + + public void Refresh(Mobile mobile, bool reSearch = true) { - if (update) + if (reSearch) { - _mobile.SendGump(new SpawnerControllerGump(_mobile, _page, _copy, _search)); - } - else - { - _mobile.SendGump(this); + SearchSpawner(); } + + mobile.SendGump(this); } public override void OnResponse(NetState sender, in RelayInfo info) { + var mobile = sender.Mobile; + if (info.ButtonID == 0) + { + return; + } + var buttonID = info.ButtonID - 1; var type = buttonID % TypeCount; var index = buttonID / TypeCount; switch (type) { - case -1: - { - return; - } case 1: { - _page = 0; if (index < 4) { _search.Type = (SpawnSearchType)index; @@ -435,7 +544,6 @@ public class SpawnerControllerGump : GumpGrid else if (index == 4) { var entry = info.GetTextEntry(0xFFFF); - if (entry?.Length > 0) { _search.SearchPattern = entry; @@ -458,148 +566,141 @@ public class SpawnerControllerGump : GumpGrid else if (index == 7 && _copy != null) { var newSpawner = new Spawner(); - FullCopy(_copy, newSpawner); - newSpawner.Map = _mobile.Map; - newSpawner.Location = _mobile.Location; + newSpawner.Map = mobile.Map; + newSpawner.Location = mobile.Location; newSpawner.Stop(); newSpawner.Start(); newSpawner.Respawn(); } + _page = 0; break; } - //copy + case 2: { if (index < _spawners.Length) { _copy = _spawners[index]; } - break; } - //paste props + case 3: { if (index < _spawners.Length && _copy != null) { CopyProperty(_copy, _spawners[index]); } - break; } - //paste entry + case 4: { if (index < _spawners.Length && _copy != null) { CopyEntry(_copy, _spawners[index]); } - break; } - //save - case 8: - { - if (index < _spawners.Length) - { - var spawner = _spawners[index]; - var indexEntry = index >= _lineCount ? (index - _lineCount * _page) * EntryCount : index * EntryCount; - - var name = info.GetTextEntry(indexEntry); - if (name?.Length > 0) - { - spawner.Name = name; - } - - if (int.TryParse(info.GetTextEntry(indexEntry + 1), out var walkRange)) - { - spawner.WalkingRange = walkRange; - } - - // Only update HomeRange if text entry exists (HomeRange-style bounds) - // Custom bounds show a label instead, so this will be null/empty and skip - if (int.TryParse(info.GetTextEntry(indexEntry + 2), out var homeRange)) - { - spawner.HomeRange = homeRange; - } - - if (TimeSpan.TryParse(info.GetTextEntry(indexEntry + 3), out var minDelay)) - { - spawner.MinDelay = minDelay; - } - - if (TimeSpan.TryParse(info.GetTextEntry(indexEntry + 4), out var maxDelay)) - { - spawner.MaxDelay = maxDelay; - } - } - - break; - } - //go case 5: { if (index < _spawners.Length) { var spawner = _spawners[index]; - _mobile.Map = spawner.Map; - _mobile.Location = spawner.Location; + mobile.Map = spawner.Map; + mobile.Location = spawner.Location; } - Refresh(false); + Refresh(mobile, reSearch: false); return; } - //open entry + case 6: case 7: { if (index < _spawners.Length) { var spawner = _spawners[index]; - Refresh(); + Refresh(mobile); if (type == 7) { - _mobile.SendGump(new SpawnerGump(spawner)); + mobile.SendGump(new SpawnerGump(spawner)); } else { - _mobile.SendGump(new PropertiesGump(_mobile, spawner)); + mobile.SendGump(new PropertiesGump(mobile, spawner)); } - return; } - break; } - //delete + + case 8: + { + if (index < _spawners.Length) + { + var spawner = _spawners[index]; + var entryIndex = index >= _lineCount ? (index - _lineCount * _page) * EntryCount : index * EntryCount; + + var name = info.GetTextEntry(entryIndex); + if (name?.Length > 0) + { + spawner.Name = name; + } + + if (int.TryParse(info.GetTextEntry(entryIndex + 1), out var walkRange)) + { + spawner.WalkingRange = walkRange; + } + + if (int.TryParse(info.GetTextEntry(entryIndex + 2), out var homeRange)) + { + spawner.HomeRange = homeRange; + } + + if (TimeSpan.TryParse(info.GetTextEntry(entryIndex + 3), out var minDelay)) + { + spawner.MinDelay = minDelay; + } + + if (TimeSpan.TryParse(info.GetTextEntry(entryIndex + 4), out var maxDelay)) + { + spawner.MaxDelay = maxDelay; + } + } + break; + } + case 9: { if (index < _spawners.Length) { _spawners[index].Delete(); } - break; } + case 10: { _page++; - break; + Refresh(mobile, reSearch: false); + return; } + case 11: { if (_page > 0) { _page--; } - - break; + Refresh(mobile, reSearch: false); + return; } } - Refresh(); + Refresh(mobile); } } diff --git a/Projects/UOContent/Gumps/Base/GridLayout/GridBuilderExtensions.cs b/Projects/UOContent/Gumps/Base/GridLayout/GridBuilderExtensions.cs new file mode 100644 index 000000000..7ba9ecea4 --- /dev/null +++ b/Projects/UOContent/Gumps/Base/GridLayout/GridBuilderExtensions.cs @@ -0,0 +1,512 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: GridBuilderExtensions.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Runtime.CompilerServices; +using Server.Buffers; + +namespace Server.Gumps; + +/// +/// Extension methods for DynamicGumpBuilder that accept GridCell for positioning. +/// Provides ergonomic grid-based gump building with zero allocations. +/// +public static class GridBuilderExtensions +{ + extension(ref DynamicGumpBuilder builder) + { + /// + /// Adds a background element filling the entire cell. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddBackground(in GridCell cell, int gumpId) => + builder.AddBackground(cell.X, cell.Y, cell.Width, cell.Height, gumpId); + + /// + /// Adds an alpha region filling the entire cell. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddAlphaRegion(in GridCell cell) => + builder.AddAlphaRegion(cell.X, cell.Y, cell.Width, cell.Height); + + /// + /// Adds a tiled image filling the entire cell. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddImageTiled(in GridCell cell, int gumpId) => + builder.AddImageTiled(cell.X, cell.Y, cell.Width, cell.Height, gumpId); + + /// + /// Adds an image at the cell position with optional offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddImage( + in GridCell cell, + int gumpId, + int hue = 0, + int offsetX = 0, + int offsetY = 0 + ) => builder.AddImage(cell.X + offsetX, cell.Y + offsetY, gumpId, hue); + + /// + /// Adds an item display at the cell position with optional offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddItem( + in GridCell cell, + int itemId, + int hue = 0, + int offsetX = 0, + int offsetY = 0 + ) => builder.AddItem(cell.X + offsetX, cell.Y + offsetY, itemId, hue); + + /// + /// Adds a label at the cell position with optional offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddLabel( + in GridCell cell, + int hue, + ReadOnlySpan text, + int offsetX = 0, + int offsetY = 0 + ) => builder.AddLabel(cell.X + offsetX, cell.Y + offsetY, hue, text); + + /// + /// Adds a label at the cell position with optional offset using interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddLabel( + in GridCell cell, + int hue, + ref RawInterpolatedStringHandler handler, + int offsetX = 0, + int offsetY = 0 + ) + { + builder.AddLabel(cell.X + offsetX, cell.Y + offsetY, hue, handler.Text); + handler.Clear(); + } + + /// + /// Adds a cropped label filling the cell with optional offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddLabelCropped( + in GridCell cell, + int hue, + ReadOnlySpan text, + int offsetX = 0, + int offsetY = 0 + ) => builder.AddLabelCropped( + cell.X + offsetX, + cell.Y + offsetY, + cell.Width - offsetX, + cell.Height - offsetY, + hue, + text + ); + + /// + /// Adds a cropped label filling the cell with optional offset using interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddLabelCropped( + in GridCell cell, + int hue, + ref RawInterpolatedStringHandler handler, + int offsetX = 0, + int offsetY = 0 + ) + { + builder.AddLabelCropped( + cell.X + offsetX, + cell.Y + offsetY, + cell.Width - offsetX, + cell.Height - offsetY, + hue, + handler.Text + ); + handler.Clear(); + } + + /// + /// Adds HTML text filling the entire cell. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddHtml( + in GridCell cell, + ReadOnlySpan text, + ReadOnlySpan color = default, + int size = -1, + byte fontStyle = 0, + TextAlignment align = TextAlignment.Left, + bool background = false, + bool scrollbar = false + ) => builder.AddHtml( + cell.X, + cell.Y, + cell.Width, + cell.Height, + text, + color, + size, + fontStyle, + align, + background, + scrollbar + ); + + /// + /// Adds HTML text filling the entire cell using interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddHtml( + in GridCell cell, + ref RawInterpolatedStringHandler handler, + ReadOnlySpan color = default, + int size = -1, + byte fontStyle = 0, + TextAlignment align = TextAlignment.Left, + bool background = false, + bool scrollbar = false + ) + { + builder.AddHtml( + cell.X, + cell.Y, + cell.Width, + cell.Height, + handler.Text, + color, + size, + fontStyle, + align, + background, + scrollbar + ); + handler.Clear(); + } + + /// + /// Adds HTML text filling the cell with offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddHtml( + in GridCell cell, + ReadOnlySpan text, + int offsetX, + int offsetY, + ReadOnlySpan color = default, + int size = -1, + byte fontStyle = 0, + TextAlignment align = TextAlignment.Left, + bool background = false, + bool scrollbar = false + ) => builder.AddHtml( + cell.X + offsetX, + cell.Y + offsetY, + cell.Width - offsetX, + cell.Height - offsetY, + text, + color, + size, + fontStyle, + align, + background, + scrollbar + ); + + /// + /// Adds HTML text filling the cell with offset using interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddHtml( + in GridCell cell, + ref RawInterpolatedStringHandler handler, + int offsetX, + int offsetY, + ReadOnlySpan color = default, + int size = -1, + byte fontStyle = 0, + TextAlignment align = TextAlignment.Left, + bool background = false, + bool scrollbar = false + ) + { + builder.AddHtml( + cell.X + offsetX, + cell.Y + offsetY, + cell.Width - offsetX, + cell.Height - offsetY, + handler.Text, + color, + size, + fontStyle, + align, + background, + scrollbar + ); + handler.Clear(); + } + + /// + /// Adds a localized HTML element filling the cell. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddHtmlLocalized( + in GridCell cell, + int number, + bool background = false, + bool scrollbar = false + ) => builder.AddHtmlLocalized(cell.X, cell.Y, cell.Width, cell.Height, number, background, scrollbar); + + /// + /// Adds a localized HTML element with color. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddHtmlLocalized( + in GridCell cell, + int number, + int color, + bool background = false, + bool scrollbar = false + ) => builder.AddHtmlLocalized(cell.X, cell.Y, cell.Width, cell.Height, number, color, background, scrollbar); + + /// + /// Adds a localized HTML element with arguments. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddHtmlLocalized( + in GridCell cell, + int number, + ReadOnlySpan args, + int color, + bool background = false, + bool scrollbar = false + ) => builder.AddHtmlLocalized(cell.X, cell.Y, cell.Width, cell.Height, number, args, color, background, scrollbar); + + /// + /// Adds a localized HTML element with arguments using interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddHtmlLocalized( + in GridCell cell, + int number, + ref RawInterpolatedStringHandler handler, + int color, + bool background = false, + bool scrollbar = false + ) => builder.AddHtmlLocalized(cell.X, cell.Y, cell.Width, cell.Height, number, ref handler, color, background, scrollbar); + + /// + /// Adds a button at the cell position with optional offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddButton( + in GridCell cell, + int normalId, + int pressedId, + int buttonId, + GumpButtonType type = GumpButtonType.Reply, + int param = 0, + int offsetX = 0, + int offsetY = 0 + ) => builder.AddButton(cell.X + offsetX, cell.Y + offsetY, normalId, pressedId, buttonId, type, param); + + /// + /// Adds a checkbox at the cell position with optional offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddCheckbox( + in GridCell cell, + int inactiveId, + int activeId, + bool selected, + int switchId, + int offsetX = 0, + int offsetY = 0 + ) => builder.AddCheckbox(cell.X + offsetX, cell.Y + offsetY, inactiveId, activeId, selected, switchId); + + /// + /// Adds a radio button at the cell position with optional offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddRadio( + in GridCell cell, + int inactiveId, + int activeId, + bool selected, + int switchId, + int offsetX = 0, + int offsetY = 0 + ) => builder.AddRadio(cell.X + offsetX, cell.Y + offsetY, inactiveId, activeId, selected, switchId); + + /// + /// Adds a text entry filling the cell. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddTextEntry( + in GridCell cell, + int hue, + int entryId, + ReadOnlySpan initialText = default + ) => builder.AddTextEntry(cell.X, cell.Y, cell.Width, cell.Height, hue, entryId, initialText); + + /// + /// Adds a text entry filling the cell using interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddTextEntry( + in GridCell cell, + int hue, + int entryId, + ref RawInterpolatedStringHandler handler + ) + { + builder.AddTextEntry(cell.X, cell.Y, cell.Width, cell.Height, hue, entryId, handler.Text); + handler.Clear(); + } + + /// + /// Adds a text entry with offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddTextEntry( + in GridCell cell, + int hue, + int entryId, + int offsetX, + int offsetY, + ReadOnlySpan initialText = default + ) => builder.AddTextEntry( + cell.X + offsetX, + cell.Y + offsetY, + cell.Width - offsetX, + cell.Height - offsetY, + hue, + entryId, + initialText + ); + + /// + /// Adds a text entry with offset using interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddTextEntry( + in GridCell cell, + int hue, + int entryId, + int offsetX, + int offsetY, + ref RawInterpolatedStringHandler handler + ) + { + builder.AddTextEntry( + cell.X + offsetX, + cell.Y + offsetY, + cell.Width - offsetX, + cell.Height - offsetY, + hue, + entryId, + handler.Text + ); + handler.Clear(); + } + + /// + /// Adds a limited text entry filling the cell. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddTextEntryLimited( + in GridCell cell, + int hue, + int entryId, + int size, + ReadOnlySpan initialText = default + ) => builder.AddTextEntryLimited(cell.X, cell.Y, cell.Width, cell.Height, hue, entryId, initialText, size); + + /// + /// Adds a limited text entry filling the cell using interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddTextEntryLimited( + in GridCell cell, + int hue, + int entryId, + int size, + ref RawInterpolatedStringHandler handler + ) + { + builder.AddTextEntryLimited(cell.X, cell.Y, cell.Width, cell.Height, hue, entryId, handler.Text, size); + handler.Clear(); + } + + /// + /// Adds an image-tiled button at the cell position. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddImageTiledButton( + in GridCell cell, + int normalId, + int pressedId, + int buttonId, + GumpButtonType type, + int param, + int itemId, + int hue, + int localizedTooltip = -1 + ) => builder.AddImageTiledButton( + cell.X, + cell.Y, + normalId, + pressedId, + buttonId, + type, + param, + itemId, + hue, + cell.Width, + cell.Height, + localizedTooltip + ); + + /// + /// Adds a sprite image using the cell dimensions. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddSpriteImage( + in GridCell cell, + int gumpId, + int sx, + int sy + ) => builder.AddSpriteImage(cell.X, cell.Y, gumpId, cell.Width, cell.Height, sx, sy); + + /// + /// Adds a sprite image at the cell position with optional offset. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddSpriteImage( + in GridCell cell, + int gumpId, + int width, + int height, + int sx, + int sy, + int offsetX = 0, + int offsetY = 0 + ) => builder.AddSpriteImage(cell.X + offsetX, cell.Y + offsetY, gumpId, width, height, sx, sy); + } +} diff --git a/Projects/UOContent/Gumps/Base/GridLayout/GridCalculator.cs b/Projects/UOContent/Gumps/Base/GridLayout/GridCalculator.cs new file mode 100644 index 000000000..05003e9e3 --- /dev/null +++ b/Projects/UOContent/Gumps/Base/GridLayout/GridCalculator.cs @@ -0,0 +1,242 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: GridCalculator.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Runtime.CompilerServices; + +namespace Server.Gumps; + +/// +/// Static helper for calculating grid cell positions without heap allocations. +/// All results are written to caller-provided spans using stackalloc. +/// +public static class GridCalculator +{ + /// + /// Maximum supported columns or rows per grid. + /// + public const int MaxTracks = 32; + + /// + /// Computes track sizes from size specifications. + /// Writes positions and sizes to the provided spans. + /// + /// The size specifications for each track. + /// The total available size (width or height). + /// The starting position (x or y offset). + /// Output: position of each track. + /// Output: size of each track. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ComputeTrackSizes( + ReadOnlySpan specs, + int totalSize, + int origin, + Span positions, + Span sizes + ) + { + var trackCount = specs.Length; + + // First pass: calculate absolute/percent sizes and count star tracks + var remainingSize = totalSize; + var starCount = 0; + + for (var i = 0; i < trackCount; i++) + { + var spec = specs[i]; + switch (spec.Type) + { + case GridSizeType.Absolute: + { + sizes[i] = spec.Value; + remainingSize -= spec.Value; + break; + } + case GridSizeType.Percent: + { + sizes[i] = totalSize * spec.Value / 100; + remainingSize -= sizes[i]; + break; + } + case GridSizeType.Star: + { + starCount++; + sizes[i] = -1; // Mark as star for second pass + break; + } + } + } + + // Second pass: distribute remaining space to star tracks + if (starCount > 0 && remainingSize > 0) + { + var starSize = remainingSize / starCount; + for (var i = 0; i < trackCount; i++) + { + if (sizes[i] == -1) + { + sizes[i] = starSize; + } + } + } + else + { + // No star tracks or no remaining space - set any marked stars to 0 + for (var i = 0; i < trackCount; i++) + { + if (sizes[i] < 0) + { + sizes[i] = 0; + } + } + } + + // Third pass: calculate positions + var pos = origin; + for (var i = 0; i < trackCount; i++) + { + positions[i] = pos; + pos += sizes[i]; + } + } + + /// + /// Computes uniform track sizes (equal-sized cells). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ComputeUniformTrackSizes( + int trackCount, + int totalSize, + int origin, + Span positions, + Span sizes + ) + { + var trackSize = totalSize / trackCount; + var pos = origin; + + for (var i = 0; i < trackCount; i++) + { + positions[i] = pos; + sizes[i] = trackSize; + pos += trackSize; + } + } + + /// + /// Parses a size specification string and computes track sizes. + /// + /// Space-separated size specification (e.g., "10* * 100"). + /// The total available size. + /// The starting position. + /// Output: position of each track. + /// Output: size of each track. + /// The number of tracks computed. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int ComputeFromSpec( + ReadOnlySpan sizeSpec, + int totalSize, + int origin, + Span positions, + Span sizes + ) => ComputeFromSpec(sizeSpec, totalSize, origin, 0, positions, sizes); + + /// + /// Parses a size specification string and computes track sizes with gaps between tracks. + /// + /// Space-separated size specification (e.g., "10* * 100"). + /// The total available size. + /// The starting position. + /// The gap size between tracks. + /// Output: position of each track. + /// Output: size of each track. + /// The number of tracks computed. + public static int ComputeFromSpec( + ReadOnlySpan sizeSpec, + int totalSize, + int origin, + int gap, + Span positions, + Span sizes) + { + Span specs = stackalloc GridSizeSpec[MaxTracks]; + var trackCount = GridSizeSpec.ParseAll(sizeSpec, specs); + + if (trackCount > 0) + { + // Subtract total gap space from available size before computing track sizes + var totalGapSpace = gap * (trackCount - 1); + var availableSize = totalSize - totalGapSpace; + + ComputeTrackSizes(specs[..trackCount], availableSize, origin, positions, sizes); + + // Adjust positions to account for gaps + if (gap > 0) + { + for (var i = 1; i < trackCount; i++) + { + positions[i] += gap * i; + } + } + } + + return trackCount; + } + + /// + /// Gets a cell at the specified column and row from pre-computed grid arrays. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static GridCell GetCell( + ReadOnlySpan columnPositions, + ReadOnlySpan columnWidths, + ReadOnlySpan rowPositions, + ReadOnlySpan rowHeights, + int column, + int row + ) => new(columnPositions[column], rowPositions[row], columnWidths[column], rowHeights[row]); + + /// + /// Gets a cell spanning multiple columns and/or rows from pre-computed grid arrays. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static GridCell GetCell( + ReadOnlySpan columnPositions, + ReadOnlySpan columnWidths, + ReadOnlySpan rowPositions, + ReadOnlySpan rowHeights, + int column, + int row, + int columnSpan, + int rowSpan + ) + { + var width = 0; + var endCol = Math.Min(column + columnSpan, columnWidths.Length); + for (var c = column; c < endCol; c++) + { + width += columnWidths[c]; + } + + var height = 0; + var endRow = Math.Min(row + rowSpan, rowHeights.Length); + for (var r = row; r < endRow; r++) + { + height += rowHeights[r]; + } + + return new GridCell(columnPositions[column], rowPositions[row], width, height); + } +} diff --git a/Projects/UOContent/Gumps/Base/GridLayout/GridCell.cs b/Projects/UOContent/Gumps/Base/GridLayout/GridCell.cs new file mode 100644 index 000000000..0eaa97b1d --- /dev/null +++ b/Projects/UOContent/Gumps/Base/GridLayout/GridCell.cs @@ -0,0 +1,114 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: GridCell.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Runtime.CompilerServices; + +namespace Server.Gumps; + +/// +/// Represents a pre-calculated cell position within a grid layout. +/// This is a value type with zero heap allocations. +/// +public ref struct GridCell +{ + public readonly int X; + public readonly int Y; + public readonly int Width; + public readonly int Height; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GridCell(int x, int y, int width, int height) + { + X = x; + Y = y; + Width = width; + Height = height; + } + + /// + /// Gets the right edge X coordinate (X + Width). + /// + public int Right + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => X + Width; + } + + /// + /// Gets the bottom edge Y coordinate (Y + Height). + /// + public int Bottom + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Y + Height; + } + + /// + /// Gets the horizontal center X coordinate. + /// + public int CenterX + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => X + Width / 2; + } + + /// + /// Gets the vertical center Y coordinate. + /// + public int CenterY + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Y + Height / 2; + } + + /// + /// Creates a new cell with uniform margin inset on all sides. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GridCell Inset(int margin) => + new(X + margin, Y + margin, Width - margin * 2, Height - margin * 2); + + /// + /// Creates a new cell with separate horizontal and vertical margins. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GridCell Inset(int horizontal, int vertical) => + new(X + horizontal, Y + vertical, Width - horizontal * 2, Height - vertical * 2); + + /// + /// Creates a new cell with individual margins for each side. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GridCell Inset(int left, int top, int right, int bottom) => + new(X + left, Y + top, Width - left - right, Height - top - bottom); + + /// + /// Creates a new cell with an offset applied to the position. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GridCell Offset(int offsetX, int offsetY) => new(X + offsetX, Y + offsetY, Width, Height); + + /// + /// Creates a new cell with a different width. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GridCell WithWidth(int width) => new(X, Y, width, Height); + + /// + /// Creates a new cell with a different height. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GridCell WithHeight(int height) => new(X, Y, Width, height); +} diff --git a/Projects/UOContent/Gumps/Base/GridLayout/GridEntryExtensions.cs b/Projects/UOContent/Gumps/Base/GridLayout/GridEntryExtensions.cs new file mode 100644 index 000000000..17975d1ef --- /dev/null +++ b/Projects/UOContent/Gumps/Base/GridLayout/GridEntryExtensions.cs @@ -0,0 +1,222 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: GridEntryExtensions.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Runtime.CompilerServices; +using Server.Buffers; + +namespace Server.Gumps; + +/// +/// Extension methods for DynamicGumpBuilder that provide BaseGridGump-style entry methods. +/// These combine a tiled background with content, matching the AddEntry* pattern. +/// +public static class GridEntryExtensions +{ + extension(ref DynamicGumpBuilder builder) + { + /// + /// Adds an entry with label (EntryGumpID background + cropped label). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddEntryLabel( + in GridCell cell, + in GridEntryStyle style, + ReadOnlySpan text + ) + { + builder.AddImageTiled(cell, style.EntryGumpID); + builder.AddLabelCropped(cell, style.TextHue, text, style.TextOffsetX); + } + + /// + /// Adds an entry with label using interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddEntryLabel( + in GridCell cell, + in GridEntryStyle style, + ref RawInterpolatedStringHandler handler + ) + { + builder.AddImageTiled(cell, style.EntryGumpID); + builder.AddLabelCropped(cell, style.TextHue, ref handler, style.TextOffsetX); + } + + /// + /// Adds an entry with HTML (EntryGumpID background + HTML text). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddEntryHtml( + in GridCell cell, + in GridEntryStyle style, + ReadOnlySpan text + ) + { + builder.AddImageTiled(cell, style.EntryGumpID); + builder.AddHtml(cell, text, style.TextOffsetX, 0); + } + + /// + /// Adds an entry with HTML using interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddEntryHtml( + in GridCell cell, + in GridEntryStyle style, + ref RawInterpolatedStringHandler handler + ) + { + builder.AddImageTiled(cell, style.EntryGumpID); + builder.AddHtml(cell, ref handler, style.TextOffsetX, 0); + } + + /// + /// Adds a header entry (HeaderGumpID background only). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddEntryHeader( + in GridCell cell, + in GridEntryStyle style + ) + { + builder.AddImageTiled(cell, style.HeaderGumpID); + } + + /// + /// Adds an entry with a centered button (HeaderGumpID background + centered button). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddEntryButton( + in GridCell cell, + in GridEntryStyle style, + int normalId, + int pressedId, + int buttonId, + int buttonWidth, + int buttonHeight + ) + { + builder.AddImageTiled(cell, style.HeaderGumpID); + builder.AddButton( + cell, normalId, pressedId, buttonId, + offsetX: (cell.Width - buttonWidth) / 2, + offsetY: (cell.Height - buttonHeight) / 2 + ); + } + + /// + /// Adds an entry with arrow left button. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddEntryArrowLeft( + in GridCell cell, + in GridEntryStyle style, + int buttonId + ) + { + builder.AddEntryButton( + cell, style, + GridEntryStyle.ArrowLeftID1, + GridEntryStyle.ArrowLeftID2, + buttonId, + GridEntryStyle.ArrowWidth, + GridEntryStyle.ArrowHeight + ); + } + + /// + /// Adds an entry with arrow right button. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddEntryArrowRight( + in GridCell cell, + in GridEntryStyle style, + int buttonId + ) + { + builder.AddEntryButton( + cell, style, + GridEntryStyle.ArrowRightID1, + GridEntryStyle.ArrowRightID2, + buttonId, + GridEntryStyle.ArrowWidth, + GridEntryStyle.ArrowHeight + ); + } + + /// + /// Adds an entry with text input (EntryGumpID background + text entry). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddEntryText( + in GridCell cell, + in GridEntryStyle style, + int entryId, + ReadOnlySpan initialText = default + ) + { + builder.AddImageTiled(cell, style.EntryGumpID); + builder.AddTextEntry(cell, style.TextHue, entryId, style.TextOffsetX, 0, initialText); + } + + /// + /// Adds an entry with text input using interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddEntryText( + in GridCell cell, + in GridEntryStyle style, + int entryId, + ref RawInterpolatedStringHandler handler + ) + { + builder.AddImageTiled(cell, style.EntryGumpID); + builder.AddTextEntry(cell, style.TextHue, entryId, style.TextOffsetX, 0, ref handler); + } + + /// + /// Adds a blank line (BackGumpID + 4 background across full width). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddEntryBlank( + in GridCell cell, + in GridEntryStyle style + ) + { + builder.AddImageTiled(cell, style.BackGumpID + 4); + } + + /// + /// Adds the standard grid background (outer background + inner offset region). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddGridBackground( + int width, + int height, + in GridEntryStyle style + ) + { + builder.AddBackground(0, 0, width, height, style.BackGumpID); + builder.AddImageTiled( + style.BorderSize, + style.BorderSize, + width - style.BorderSize * 2, + height - style.BorderSize * 2, + style.OffsetGumpID + ); + } + } +} diff --git a/Projects/UOContent/Gumps/Base/GridLayout/GridEntryStyle.cs b/Projects/UOContent/Gumps/Base/GridLayout/GridEntryStyle.cs new file mode 100644 index 000000000..d5b4193c2 --- /dev/null +++ b/Projects/UOContent/Gumps/Base/GridLayout/GridEntryStyle.cs @@ -0,0 +1,165 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: GridEntryStyle.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +namespace Server.Gumps; + +/// +/// Defines the visual style for grid-based entry layouts. +/// Mirrors the virtual properties from BaseGridGump for easy migration. +/// +public readonly struct GridEntryStyle +{ + /// + /// Default style matching BaseGridGump defaults. + /// + public static readonly GridEntryStyle Default = new( + entryGumpID: 0x0BBC, + headerGumpID: 0x0E14, + offsetGumpID: 0x0A40, + backGumpID: 0x13BE, + textHue: 0, + textOffsetX: 2, + entryHeight: 20, + borderSize: 10, + offsetSize: 1 + ); + + /// + /// Background gump ID for data entry cells. + /// + public readonly int EntryGumpID; + + /// + /// Background gump ID for header/button cells. + /// + public readonly int HeaderGumpID; + + /// + /// Gump ID for the offset/separator region. + /// + public readonly int OffsetGumpID; + + /// + /// Gump ID for the main background. + /// + public readonly int BackGumpID; + + /// + /// Text hue for labels. + /// + public readonly int TextHue; + + /// + /// Horizontal offset for text within cells. + /// + public readonly int TextOffsetX; + + /// + /// Height of each entry row. + /// + public readonly int EntryHeight; + + /// + /// Size of the outer border. + /// + public readonly int BorderSize; + + /// + /// Size of the offset/gap between cells. + /// + public readonly int OffsetSize; + + // Arrow button constants (matching BaseGridGump) + public const int ArrowLeftID1 = 0x15E3; + public const int ArrowLeftID2 = 0x15E7; + public const int ArrowRightID1 = 0x15E1; + public const int ArrowRightID2 = 0x15E5; + public const int ArrowWidth = 16; + public const int ArrowHeight = 16; + + public GridEntryStyle( + int entryGumpID, + int headerGumpID, + int offsetGumpID, + int backGumpID, + int textHue, + int textOffsetX, + int entryHeight, + int borderSize, + int offsetSize + ) + { + EntryGumpID = entryGumpID; + HeaderGumpID = headerGumpID; + OffsetGumpID = offsetGumpID; + BackGumpID = backGumpID; + TextHue = textHue; + TextOffsetX = textOffsetX; + EntryHeight = entryHeight; + BorderSize = borderSize; + OffsetSize = offsetSize; + } + + /// + /// Creates a copy with modified values. + /// + public GridEntryStyle With( + int entryGumpID = -1, + int headerGumpID = -1, + int offsetGumpID = -1, + int backGumpID = -1, + int textHue = -1, + int textOffsetX = -1, + int entryHeight = -1, + int borderSize = -1, + int offsetSize = -1 + ) => new( + entryGumpID < 0 ? EntryGumpID : entryGumpID, + headerGumpID < 0 ? HeaderGumpID : headerGumpID, + offsetGumpID < 0 ? OffsetGumpID : offsetGumpID, + backGumpID < 0 ? BackGumpID : backGumpID, + textHue < 0 ? TextHue : textHue, + textOffsetX < 0 ? TextOffsetX : textOffsetX, + entryHeight < 0 ? EntryHeight : entryHeight, + borderSize < 0 ? BorderSize : borderSize, + offsetSize < 0 ? OffsetSize : offsetSize + ); + + /// + /// Calculates the content origin X (inside borders). + /// + public int ContentOriginX => BorderSize + OffsetSize; + + /// + /// Calculates the content origin Y (inside borders). + /// + public int ContentOriginY => BorderSize + OffsetSize; + + /// + /// Calculates total width given content width. + /// + public int GetTotalWidth(int contentWidth) => contentWidth + BorderSize * 2 + OffsetSize * 2; + + /// + /// Calculates total height given number of rows. + /// + public int GetTotalHeight(int rowCount) => + BorderSize * 2 + OffsetSize * 2 + rowCount * EntryHeight + (rowCount - 1) * OffsetSize; + + /// + /// Calculates content height given number of rows. + /// + public int GetContentHeight(int rowCount) => rowCount * EntryHeight + (rowCount - 1) * OffsetSize; +} diff --git a/Projects/UOContent/Gumps/Base/GridLayout/GridSizeSpec.cs b/Projects/UOContent/Gumps/Base/GridLayout/GridSizeSpec.cs new file mode 100644 index 000000000..272b5a6d0 --- /dev/null +++ b/Projects/UOContent/Gumps/Base/GridLayout/GridSizeSpec.cs @@ -0,0 +1,162 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: GridSizeSpec.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Runtime.CompilerServices; + +namespace Server.Gumps; + +/// +/// Specifies how a grid column or row should be sized. +/// Supports absolute pixels, percentage, and star (proportional) sizing. +/// +public readonly struct GridSizeSpec +{ + /// + /// The sizing type. + /// + public readonly GridSizeType Type; + + /// + /// The value - meaning depends on Type: + /// - Absolute: pixel size + /// - Percent: percentage of total size (1-100) + /// - Star: proportional weight (1 = equal share) + /// + public readonly int Value; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private GridSizeSpec(GridSizeType type, int value) + { + Type = type; + Value = value; + } + + /// + /// Creates an absolute pixel size specification. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static GridSizeSpec Absolute(int pixels) => new(GridSizeType.Absolute, pixels); + + /// + /// Creates a star (proportional) size specification. + /// Star-sized tracks share the remaining space equally (after absolute/percent are allocated). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static GridSizeSpec Star() => new(GridSizeType.Star, 1); + + /// + /// Creates a percentage size specification. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static GridSizeSpec Percent(int percent) => new(GridSizeType.Percent, percent); + + /// + /// Parses a single size specification token. + /// Formats: + /// - "*" = star (equal share of remaining space) + /// - "10*" = 10% of total size + /// - "100" = 100 pixels absolute + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static GridSizeSpec Parse(ReadOnlySpan token) + { + token = token.Trim(); + + if (token.IsEmpty) + { + return Star(); + } + + // Check for star notation + var starIndex = token.IndexOf('*'); + + if (starIndex == -1) + { + // No star - absolute pixel value + return int.TryParse(token, out var pixels) ? Absolute(pixels) : Star(); + } + + if (starIndex == 0 && token.Length == 1) + { + // Just "*" - equal share star + return Star(); + } + + // "N*" format - percentage + var percentPart = token[..starIndex]; + return int.TryParse(percentPart, out var percent) ? Percent(percent) : Star(); + } + + /// + /// Parses a space-separated size specification string into a span of GridSizeSpec values. + /// Returns the number of specs parsed. + /// + public static int ParseAll(ReadOnlySpan sizeSpec, Span destination) + { + var count = 0; + var remaining = sizeSpec; + + while (!remaining.IsEmpty && count < destination.Length) + { + // Skip leading whitespace + remaining = remaining.TrimStart(); + if (remaining.IsEmpty) + { + break; + } + + // Find end of current token + var spaceIndex = remaining.IndexOf(' '); + ReadOnlySpan token; + + if (spaceIndex < 0) + { + token = remaining; + remaining = default; + } + else + { + token = remaining[..spaceIndex]; + remaining = remaining[(spaceIndex + 1)..]; + } + + destination[count++] = Parse(token); + } + + return count; + } +} + +/// +/// Specifies the type of grid size specification. +/// +public enum GridSizeType : byte +{ + /// + /// Fixed pixel size. + /// + Absolute, + + /// + /// Star sizing - shares remaining space equally with other star-sized tracks. + /// + Star, + + /// + /// Percentage of total available size. + /// + Percent +} diff --git a/Projects/UOContent/Gumps/Base/GridLayout/ListViewLayout.cs b/Projects/UOContent/Gumps/Base/GridLayout/ListViewLayout.cs new file mode 100644 index 000000000..1b21221ba --- /dev/null +++ b/Projects/UOContent/Gumps/Base/GridLayout/ListViewLayout.cs @@ -0,0 +1,239 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ListViewLayout.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Runtime.CompilerServices; + +namespace Server.Gumps; + +/// +/// Calculates pagination and provides helper methods for a paginated list view. +/// This struct does NOT hold column data - column positions/widths are passed +/// separately via spans to avoid ref struct limitations. +/// +public ref struct ListViewLayout +{ + private readonly int _originY; + private readonly int _headerHeight; + private readonly int _rowHeight; + + /// + /// The total number of items in the list. + /// + public readonly int TotalItems; + + /// + /// The number of items that can be displayed per page. + /// + public readonly int ItemsPerPage; + + /// + /// The current page index (0-based). + /// + public readonly int CurrentPage; + + /// + /// The total number of pages. + /// + public readonly int TotalPages; + + /// + /// The index of the first item on the current page. + /// + public readonly int StartIndex; + + /// + /// The number of items visible on the current page. + /// + public readonly int VisibleCount; + + /// + /// Whether there is a previous page. + /// + public readonly bool CanGoBack; + + /// + /// Whether there is a next page. + /// + public readonly bool CanGoNext; + + /// + /// The number of columns in the list. + /// + public readonly int ColumnCount; + + /// + /// The height of each data row. + /// + public int RowHeight + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _rowHeight; + } + + /// + /// The height of the header row. + /// + public int HeaderHeight + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _headerHeight; + } + + /// + /// Creates a new ListViewLayout and computes column positions into the provided spans. + /// + /// The X origin of the list area. + /// The Y origin of the list area. + /// The total width of the list area. + /// The total height of the list area. + /// The total number of items in the data source. + /// The current page index (0-based). + /// The height of each data row. + /// The height of the header row (0 for no header). + /// Space-separated column size specification. + /// Span to receive calculated column X positions. + /// Span to receive calculated column widths. + /// The configured ListViewLayout. + public static ListViewLayout Create( + int originX, + int originY, + int width, + int height, + int totalItems, + int currentPage, + int rowHeight, + int headerHeight, + ReadOnlySpan columnSpec, + Span columnPositions, + Span columnWidths + ) + { + // Parse and compute column widths + var columnCount = GridCalculator.ComputeFromSpec(columnSpec, width, originX, columnPositions, columnWidths); + + return new ListViewLayout( + originY, + headerHeight, rowHeight, + totalItems, currentPage, + height, + columnCount + ); + } + + private ListViewLayout( + int originY, + int headerHeight, + int rowHeight, + int totalItems, + int currentPage, + int totalHeight, + int columnCount + ) + { + _originY = originY; + _headerHeight = headerHeight; + _rowHeight = rowHeight; + ColumnCount = columnCount; + + TotalItems = totalItems; + + // Calculate items per page based on available height + var contentHeight = totalHeight - headerHeight; + ItemsPerPage = contentHeight / rowHeight; + + // Calculate pagination + if (totalItems > 0 && ItemsPerPage > 0) + { + TotalPages = (totalItems + ItemsPerPage - 1) / ItemsPerPage; + CurrentPage = Math.Clamp(currentPage, 0, TotalPages - 1); + StartIndex = CurrentPage * ItemsPerPage; + VisibleCount = Math.Min(ItemsPerPage, totalItems - StartIndex); + CanGoBack = CurrentPage > 0; + CanGoNext = CurrentPage < TotalPages - 1; + } + else + { + TotalPages = 1; + CurrentPage = 0; + StartIndex = 0; + VisibleCount = 0; + CanGoBack = false; + CanGoNext = false; + } + } + + /// + /// Gets the header cell for the specified column using pre-computed column data. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GridCell GetHeaderCell(int column, ReadOnlySpan columnPositions, ReadOnlySpan columnWidths) => + new(columnPositions[column], _originY, columnWidths[column], _headerHeight); + + /// + /// Gets the data cell for the specified visible row and column. + /// + /// The 0-based index within visible rows (not data index). + /// The column index. + /// Pre-computed column X positions. + /// Pre-computed column widths. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GridCell GetRowCell( + int visibleRowIndex, int column, ReadOnlySpan columnPositions, ReadOnlySpan columnWidths + ) => new( + columnPositions[column], + _originY + _headerHeight + visibleRowIndex * _rowHeight, + columnWidths[column], + _rowHeight + ); + + /// + /// Gets the data index for a visible row. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetDataIndex(int visibleRowIndex) => StartIndex + visibleRowIndex; + + /// + /// Gets the Y position for a visible row. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetRowY(int visibleRowIndex) => _originY + _headerHeight + visibleRowIndex * _rowHeight; + + /// + /// Gets the X position of a column. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int GetColumnX(ReadOnlySpan columnPositions, int column) => columnPositions[column]; + + /// + /// Gets the width of a column. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int GetColumnWidth(ReadOnlySpan columnWidths, int column) => columnWidths[column]; + + /// + /// Gets the horizontal center of a column. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int GetColumnCenterX(ReadOnlySpan columnPositions, ReadOnlySpan columnWidths, int column) => + columnPositions[column] + columnWidths[column] / 2; + + /// + /// Gets the right edge of a column. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int GetColumnRightX(ReadOnlySpan columnPositions, ReadOnlySpan columnWidths, int column) => + columnPositions[column] + columnWidths[column]; +} diff --git a/Projects/UOContent/Gumps/Base/GumpColors.cs b/Projects/UOContent/Gumps/Base/GumpColors.cs new file mode 100644 index 000000000..9933c919c --- /dev/null +++ b/Projects/UOContent/Gumps/Base/GumpColors.cs @@ -0,0 +1,22 @@ +namespace Server.Gumps; + +public class GumpTextColors +{ + public const string Gold = "#ffc959"; + public const string White = "#ffffff"; + public const string Blue = "#1D63DC"; + public const string LightBlue = "#7799EE"; + public const string Green = "#80E732"; + public const string Orange = "#F28B00"; + public const string Purple = "#F3ACF6"; + public const string Red = "#B52B2D"; + public const string BrightRed = "#FF0000"; + public const string LightGreen = "#E6FFC0"; + public const string Yellow = "#FFFFBB"; +} + +public class GumpHues +{ + public const int Black = 0; + public const int White = 2049; +} diff --git a/Projects/UOContent/Gumps/Base/Legacy/GumpGrid.cs b/Projects/UOContent/Gumps/Base/Legacy/GumpGrid.cs deleted file mode 100644 index e66f393eb..000000000 --- a/Projects/UOContent/Gumps/Base/Legacy/GumpGrid.cs +++ /dev/null @@ -1,585 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2025 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: GumpGrid.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - *************************************************************************/ - -using System; -using System.Collections.Generic; -using Server.Collections; - -namespace Server.Gumps; - -public class GumpGrid : Gump -{ - private Dictionary _grids = new(); - - public GumpGrid(int x, int y) : base(x, y) - { - } - - private void AddColumn(string name, int x, int width) - { - _grids[name].Columns.Add(new Col { X = x, Width = width, }); - } - - private void AddRow(string name, int y, int height) - { - _grids[name].Rows.Add(new Row { Y = y, Height = height }); - } - - private void InitColumn(string name, int column, int w, int x) - { - var width = w / column; - for (var i = 0; i < column; i++) - { - AddColumn(name, i * width + x, width); - } - } - - private void InitRow(string name, int row, int h, int y) - { - var height = h / row; - for (var i = 0; i < row; i++) - { - AddRow(name, i * height + y, height); - } - } - - private static Swap Exist(ref PooledRefList list, int index) - { - for (var i = 0; i < list.Count; i++) - { - var item = list[i]; - if (item.Index == index) - { - return item; - } - } - - return null; - } - - private static int CalculateCustom(int column, int len, string[] sizes, ref PooledRefList list) - { - var cropSize = 0; - - for (var i = 0; i < column; i++) - { - if (sizes[i] == "*") - { - continue; - } - - //percent - var percent = sizes[i].RemoveOrdinal("*"); - var size = sizes[i] != percent ? len / 100 * int.Parse(percent) : int.Parse(percent); - - list.Add(new Swap { Index = i, Size = size }); - cropSize += size; - } - - return cropSize; - } - private void InitCustomSizeRow(string name, int height, int row, string[] sizes, int y) - { - var list = PooledRefList.Create(); - var cropHeight = CalculateCustom(row, height, sizes, ref list); - var Height = height - cropHeight; - var factor = row - list.Count; - for (var i = 0; i < row; i++) - { - var swap = Exist(ref list, i); - if (swap != null) - { - var size = swap.Size; - if (i == 0) - { - AddRow(name, y, size); - } - else - { - var col = _grids[name].Rows[i - 1]; - AddRow(name, col.Height + col.Y + y, size); - } - } - else - { - var size = Height / factor; - if (i == 0) - { - AddRow(name, y, size); - } - else - { - var col = _grids[name].Rows[i - 1]; - AddRow(name, col.Height + col.Y + y, size); - } - } - } - - list.Dispose(); - } - - private void InitCustomSizeColumn(string name, int width, int column, string[] sizes, int x) - { - var list = PooledRefList.Create(); - var cropWidth = CalculateCustom(column, width, sizes, ref list); - var Width = width - cropWidth; - var factor = column - list.Count; - - for (var i = 0; i < column; i++) - { - var swap = Exist(ref list, i); - if (swap != null) - { - var size = swap.Size; - if (i == 0) - { - AddColumn(name, x, size); - } - else - { - var col = _grids[name].Columns[i - 1]; - AddColumn(name, col.Width + col.X + x, size); - } - } - else - { - var size = Width / factor; - if (i == 0) - { - AddColumn(name, x, size); - } - else - { - var col = _grids[name].Columns[i - 1]; - AddColumn(name, col.Width + col.X + x, size); - } - } - } - } - - public Grid SubGrid( - string name, - string destGridName, - int columnStart, - int rowStart, - int createColumnsCount, - int createRowsCount, - int columnSpan = 0, - int rowSpan = 0, - string createColumnSize = "", - string createRowSize = "", - int marginX = 0, - int marginY = 0 - ) - { - if (CalculateCord( - destGridName, - columnStart, - rowStart, - columnSpan, - rowSpan, - out var x, - out var y, - out var width, - out var height - )) - { - Grid( - name, - width, - height, - createColumnsCount, - createRowsCount, - createColumnSize, - createRowSize, - x + marginX, - y + marginY - ); - } - - return CreateGrid(name, 0, 0); - } - - public Grid CreateGrid(string name, int width, int height) - { - if (!_grids.TryGetValue(name, out var grid)) - { - grid = new Grid { Name = name, Width = width, Height = height }; - _grids.Add(name, grid); - } - - return grid; - } - - public Grid Grid( - string name, - int width, - int height, - int columns, - int rows, - string columnSize = "", - string rowSize = "", - int x = 0, - int y = 0 - ) - { - if (columns < 1) - { - throw new Exception(nameof(columns)); - } - - if (rows < 1) - { - throw new Exception(nameof(columns)); - } - - var Grid = CreateGrid(name, width, height); - - if (columnSize.Length > 0) - { - var buffer = columnSize.Split(' '); - if (buffer.Length == columns) - { - InitCustomSizeColumn(name, width, columns, buffer, x); - } - else - { - throw new Exception(nameof(columnSize)); - } - } - else - { - InitColumn(name, columns, width, x); - } - - if (rowSize.Length > 0) - { - var buffer = rowSize.Split(' '); - if (buffer.Length == rows) - { - InitCustomSizeRow(name, height, rows, buffer, y); - } - else - { - throw new Exception(nameof(rowSize)); - } - } - else - { - InitRow(name, rows, height, y); - } - - return Grid; - } - - public bool CalculateCord( - string name, - int column, - int row, - int columnSpan, - int rowSpan, - out int x, - out int y, - out int width, - out int height - ) - { - x = 0; y = 0; width = 0; height = 0; - if (_grids.TryGetValue(name, out var grid)) - { - var count = columnSpan == 0 ? column + 1 : row + columnSpan; - for (var i = column; i < count; i++) - { - if (i >= grid.Columns.Count) - { - break; - } - - var dcol = grid.Columns[i]; - if (i == column) - { - x = dcol.X; - } - - width += dcol.Width; - } - - count = rowSpan == 0 ? row + 1 : row + rowSpan; - for (var i = row; i < count; i++) - { - if (i >= grid.Rows.Count) - { - break; - } - - var drow = grid.Rows[i]; - if (i == row) - { - y = drow.Y; - } - - height += drow.Height; - } - - if (width == 0) - { - width = grid.Width; - } - - if (height == 0) - { - height = grid.Height; - } - - return true; - - } - - return false; - } - - public class ListView - { - public int LineCount { get; } - public int ItemsCount { get; } - public ListItem[] Items { get; } - public ListItem Header { get; } - public int Page { get; } - public int TotalPages { get; } - public int ColHeight { get; } - public bool CanNext { get; } - public bool CanBack { get; } - public int ColsCount { get; } - - public ListView( - int itemsCount, - int columns, - int heightPerItem, - int page, - int x, - int y, - int height, - int pageItemCount, - int[] ColWidth, - int marginX = 0, - int marginY = 0, - int headerHeight = 0 - ) - { - ItemsCount = itemsCount; - var itemsPerPageCount = pageItemCount == 0 ? (height - marginY) / heightPerItem : pageItemCount; - LineCount = itemsPerPageCount; - - if (itemsCount > 0) - { - TotalPages = itemsCount / itemsPerPageCount; - if (itemsCount % itemsPerPageCount == 0) - { - TotalPages--; - } - } - - Page = page > TotalPages ? TotalPages : page; - ColsCount = columns; - ColHeight = heightPerItem; - - var index = Page * itemsPerPageCount > 0 ? Page * itemsPerPageCount : 0; - var nowPageItems = index + itemsPerPageCount; - - if (index > 0) - { - CanBack = true; - } - - if (nowPageItems < itemsCount) - { - CanNext = true; - } - - if (nowPageItems > itemsCount) - { - itemsPerPageCount = Page > 0 ? itemsCount - Page * itemsPerPageCount : itemsCount; - } - - //header - Header = new ListItem - { - X = x, - Y = y + marginY, - Index = -1, - Cols = new Col[columns], - }; - - var length = x + marginX; - for (var col = 0; col < columns; col++) - { - Header.Cols[col] = new Col(); - Header.Cols[col].Width = ColWidth[col]; - Header.Cols[col].X = length; - length += ColWidth[col]; - } - - Items = new ListItem[itemsPerPageCount]; - - for (var i = 0; i < itemsPerPageCount; i++) - { - Items[i] = new ListItem - { - X = x, - Y = y + i * heightPerItem + marginY + headerHeight, - Index = index + i, - Cols = Header.Cols - }; - } - } - } - - public ListView AddListView( - string name, - int column, - int row, - int itemsCount, - int page, - int heightPerItem, - int createColumn, - int columnSpan = 0, - int rowSpan = 0, - int width = 0, - int height = 0, - int pageItemCount = 0, - int marginX = 0, - int marginY = 0, - int headerHeight = 0, - string colSize = "" - ) - { - if (CalculateCord(name, column, row, columnSpan, rowSpan, out var x, out var y, out var w, out var h)) - { - w = width > 0 ? width : w; - h = height > 0 ? height : h; - - var colWidth = new int[createColumn]; - - if (colSize == string.Empty) - { - for (var i = 0; i < createColumn; i++) - { - colWidth[i] = w / createColumn; - } - } - else - { - var buffer = colSize.Split(' '); - - if (buffer.Length > 0) - { - const string listName = "listView"; - _grids.Add(listName, new Grid()); - - if (createColumn < buffer.Length) - { - Array.Resize(ref buffer, createColumn); - } - - InitCustomSizeColumn(listName, w, createColumn, buffer, x); - var cols = _grids[listName].Columns; - for (var i = 0; i < createColumn; i++) - { - colWidth[i] = cols[i].Width; - } - - _grids.Remove(listName); - } - } - - return new ListView( - itemsCount, - createColumn, - heightPerItem, - page, - x, - y, - h, - pageItemCount, - colWidth, - marginX, - marginY, - headerHeight - ); - } - - return null; - } -} - -public enum GridHues -{ - White = 2049, // Change to 0x480 if you have old hues.mul - Black = 0, -} - -public static class GridColors -{ - public const string Gold = "#ffc959"; - public const string White = "#ffffff"; - public const string Blue = "#1D63DC"; - public const string LightBlue = "#7799EE"; - public const string Green = "#80E732"; - public const string Orange = "#F28B00"; - public const string Purple = "#F3ACF6"; - public const string Red = "#B52B2D"; - public const string LightGreen = "#E6FFC0"; - public const string Yellow = "#FFFFBB"; -} - -public class ListItem -{ - public int X { get; set; } - public int Y { get; set; } - public int Index { get; set; } - public Col[] Cols { get; set; } -} - -public class Col -{ - public int X { get; set; } - public int Width { get; set; } - public int HCenter => X + Width / 2; - public int HEnd => X + Width; -} - -public class Row -{ - public int Y { get; set; } - public int Height { get; set; } - public int VCenter => Y + Height / 2; - public int VEnd => Y + Height; -} - -public class Grid -{ - public string Name { get; set; } - public int Height { get; set; } - public int Width { get; set; } - public List Columns { get; set; } = new(); - public List Rows { get; set; } = new(); -} - -public class Swap -{ - public int Index { get; set; } - public int Size { get; set; } -} -