Rename UiChatView -> UiText (the retail UIElement_Text class, RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655). Factory changes (DatWidgetFactory.cs): - Remove the Type-12 skip (was: no-media -> null, with-media -> UiDatElement). - Add Type 12 -> BuildText() -> UiText in the switch. - BuildText extracts the element's Direct/Normal sprite as BackgroundSprite so any dat-media the element carried keeps rendering under the text. UiText changes (renamed from UiChatView.cs): - BackgroundColor default: (0,0,0,0.35) -> (0,0,0,0) (transparent). An unbound UiText draws nothing; the controller opts in to the translucent bg. - New BackgroundSprite + SpriteResolve: optional dat state-sprite background drawn UNDER DrawFill+text (faithful UIElement_Text media support). ChatWindowController.cs (Task 5 Step 8): - Transcript property: UiChatView -> UiText. - Bind() now uses layout.FindElement(TranscriptId) as UiText (factory-built) instead of manually constructing + AddChild-ing a new UiChatView. - Sets BackgroundColor = (0,0,0,0.35) on the found widget (retail translucent bg). - Removes the tInfo null-check from the early guard (transcript is factory-built; iInfo lookup kept for the input widget which is still manually constructed). - BuildLines: UiChatView.Line -> UiText.Line throughout. Vitals frozen: the Type-12 vitals number elements are meter children and are never recursed by BuildWidget (the `if (w is not UiMeter)` gate), so they are not built as widgets and keep rendering via UiMeter.Label. Vitals fixture vitals_2100006C.json unchanged; LayoutConformanceTests + VitalsBindingTests green. Tests: - UiChatViewTests.cs -> UiTextTests.cs (class: UiTextTests, all UiChatView.* -> UiText.*) - UiChatViewDatFontTests.cs -> UiTextDatFontTests.cs (same) - DatWidgetFactoryTests: delete Type12_StylePrototype_ReturnsNull + DatWidgetFactory_Type12WithMedia_Renders; add Type12_Text_MakesUiText + DatWidgetFactory_Type12_AlwaysMakesUiText. - LayoutImporterTests: BuildFromInfos_Type12Child_IsSkipped_Type3Present updated to assert IsType<UiText> (element is now in tree, transparent, not skipped). Divergence register: AP-37 amended -- removed the "standalone Type-0 text elements skipped / dat-text widget is Plan 2" clause (now shipped as UiText); kept the meter-collapse clause and the vitals-numbers-via-UiMeter.Label clause. AP-38/AP-39/AD-28 file references updated UiChatView.cs -> UiText.cs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
468 lines
24 KiB
C#
468 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.UI;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.UI.Abstractions;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
|
|
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
|
|
///
|
|
/// <para>
|
|
/// The transcript (<c>0x10000011</c>) and input (<c>0x10000016</c>) are Type-0
|
|
/// elements whose base is a Type-12 prototype, so the importer factory skips them
|
|
/// (returns null). This controller reads their rects from the raw
|
|
/// <see cref="ElementInfo"/> tree (which contains everything) and adds the behavioral
|
|
/// widgets as children of their parent container widgets (transcript panel
|
|
/// <c>0x10000010</c> / input bar <c>0x10000013</c>) which ARE created as
|
|
/// <see cref="UiDatElement"/> nodes. The scrollbar track (<c>0x10000012</c>) is built
|
|
/// directly as a <see cref="UiScrollbar"/> by the factory (Type 11) and is bound in place
|
|
/// here. The channel menu (<c>0x10000014</c>) is still replaced with its behavioral counterpart.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class ChatWindowController
|
|
{
|
|
public const uint LayoutId = 0x21000006u;
|
|
|
|
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
|
|
private const uint RootId = 0x1000000Eu;
|
|
private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it)
|
|
private const uint TranscriptPanelId = 0x10000010u;
|
|
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
|
private const uint TrackId = 0x10000012u;
|
|
private const uint InputBarId = 0x10000013u;
|
|
private const uint MenuId = 0x10000014u;
|
|
private const uint InputId = 0x10000016u; // Type-12 prototype — skipped by factory
|
|
private const uint SendId = 0x10000019u;
|
|
private const uint MaxMinId = 0x1000046Fu;
|
|
|
|
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
|
|
private const uint TrackSprite = 0x06004C5Fu;
|
|
private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile
|
|
private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap
|
|
private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap
|
|
private const uint UpSprite = 0x06004C6Cu; // up arrow (top button)
|
|
private const uint DownSprite = 0x06004C69u; // down arrow (bottom button)
|
|
|
|
// Chat input focused-field background (element 0x10000016 Normal_focussed state).
|
|
private const uint InputFocusField = 0x060011ABu; // gold "lit" field when in write mode
|
|
|
|
// Channel menu sprite ids (confirmed in chat element dump).
|
|
private const uint MenuNormal = 0x06004D65u; // button face
|
|
private const uint MenuPressed = 0x06004D66u; // button pressed
|
|
private const uint MenuPopupBg = 0x0600124Cu; // popup panel fill (element 0x1000001C)
|
|
private const uint MenuItemRow = 0x0600124Eu; // item row bg (template 0x1000001E)
|
|
private const uint MenuItemSelected = 0x0600124Du; // active channel row
|
|
|
|
// ── Public surface ─────────────────────────────────────────────────────
|
|
|
|
/// <summary>Root element of the imported layout (the chat window chrome).</summary>
|
|
public UiElement Root { get; private set; } = null!;
|
|
|
|
/// <summary>Live chat transcript widget. Null until <see cref="Bind"/> succeeds.</summary>
|
|
public UiText Transcript { get; private set; } = null!;
|
|
|
|
/// <summary>Editable chat input widget. Null until <see cref="Bind"/> succeeds.</summary>
|
|
public UiChatInput Input { get; private set; } = null!;
|
|
|
|
/// <summary>Scrollbar widget, driven by <see cref="Transcript"/>'s scroll model.</summary>
|
|
public UiScrollbar Scrollbar { get; private set; } = null!;
|
|
|
|
/// <summary>Channel-selector menu widget.</summary>
|
|
public UiMenu Menu { get; private set; } = null!;
|
|
|
|
// ── Private state ──────────────────────────────────────────────────────
|
|
|
|
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
|
|
|
|
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
|
|
|
|
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
|
|
{
|
|
("Squelch (ignore)", null),
|
|
("Tell to Selected", null),
|
|
("Chat to All", ChatChannelKind.Say),
|
|
("Tell to Fellows", ChatChannelKind.Fellowship),
|
|
("Tell to General Chat", ChatChannelKind.General),
|
|
("Tell to LFG Chat", ChatChannelKind.Lfg),
|
|
("Tell to Society Chat", ChatChannelKind.Society),
|
|
("Tell to Monarch", ChatChannelKind.Monarch),
|
|
("Tell to Patron", ChatChannelKind.Patron),
|
|
("Tell to Vassals", ChatChannelKind.Vassals),
|
|
("Tell to Allegiance", ChatChannelKind.Allegiance),
|
|
("Tell to Trade Chat", ChatChannelKind.Trade),
|
|
("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
|
|
("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
|
|
};
|
|
|
|
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
|
|
{
|
|
ChatChannelKind.Say => "Chat",
|
|
ChatChannelKind.General => "General",
|
|
ChatChannelKind.Trade => "Trade",
|
|
ChatChannelKind.Lfg => "LFG",
|
|
ChatChannelKind.Fellowship => "Fellow",
|
|
ChatChannelKind.Allegiance => "Alleg",
|
|
ChatChannelKind.Patron => "Patron",
|
|
ChatChannelKind.Vassals => "Vassals",
|
|
ChatChannelKind.Monarch => "Monarch",
|
|
ChatChannelKind.Roleplay => "Roleplay",
|
|
ChatChannelKind.Society => "Society",
|
|
ChatChannelKind.Olthoi => "Olthoi",
|
|
_ => "Chat",
|
|
};
|
|
|
|
private static bool ChannelAvailable(ChatChannelKind k)
|
|
=> k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
|
|
|
|
/// <summary>Window height before maximize (stored to restore on un-maximize).</summary>
|
|
private float _normalHeight;
|
|
/// <summary>Window top before maximize.</summary>
|
|
private float _normalTop;
|
|
private bool _maximized;
|
|
|
|
// ── Factory ────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Bind an imported chat layout to live behavior.
|
|
///
|
|
/// <paramref name="rootInfo"/> and <paramref name="layout"/> must come from the
|
|
/// SAME <see cref="LayoutImporter"/> pass (<c>ImportInfos</c> then <c>Build</c>)
|
|
/// so rects in the info tree match the widget geometry in the layout tree.
|
|
///
|
|
/// Returns <c>null</c> if the essential transcript/input panels are missing from
|
|
/// the info tree or the widget tree (e.g. the layout dat is incomplete).
|
|
/// </summary>
|
|
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from
|
|
/// <see cref="LayoutImporter.ImportInfos"/>.</param>
|
|
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
|
|
/// <param name="vm">Chat view-model (transcript data + command routing).</param>
|
|
/// <param name="busProvider">Factory that returns the live command bus at submit time.
|
|
/// Called on every chat submit so it resolves <see cref="AcDream.UI.Abstractions.LiveCommandBus"/>
|
|
/// even when the live session is established AFTER <see cref="Bind"/> runs
|
|
/// (mirrors the ImGui <c>ChatPanel</c> which re-reads the bus each frame).</param>
|
|
/// <param name="datFont">Retail dat font for transcript + input rendering.</param>
|
|
/// <param name="debugFont">Fallback debug bitmap font (used when
|
|
/// <paramref name="datFont"/> is null).</param>
|
|
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
|
|
/// Forwarded to <see cref="UiScrollbar"/> and <see cref="UiMenu"/>.</param>
|
|
public static ChatWindowController? Bind(
|
|
ElementInfo rootInfo,
|
|
ImportedLayout layout,
|
|
ChatVM vm,
|
|
Func<ICommandBus> busProvider,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont,
|
|
Func<uint, (uint tex, int w, int h)> resolve)
|
|
{
|
|
// The transcript is now built as a UiText by the factory (Type 12 is no longer skipped).
|
|
// The input node (0x10000016) is still Type-12 based; find it in the raw ElementInfo
|
|
// tree to read its rect for the behavioral UiChatInput widget.
|
|
var iInfo = FindInfo(rootInfo, InputId);
|
|
|
|
// Their parent panels must exist as real widgets in the layout tree.
|
|
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
|
var inputBar = layout.FindElement(InputBarId);
|
|
|
|
if (iInfo is null || transcriptPanel is null || inputBar is null)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ChatWindowController.Bind: missing required elements " +
|
|
$"(iInfo={iInfo is not null}, " +
|
|
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
|
|
$"chat window will not be interactive.");
|
|
return null;
|
|
}
|
|
|
|
// LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window
|
|
// (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked
|
|
// window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the
|
|
// talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526).
|
|
// LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root,
|
|
// so using layout.Root would render the strays overlapping the real window (the
|
|
// red-striped garbage in the first live render). Use the gmMainChatUI window itself:
|
|
// GameWindow adds this to the host, which re-parents it out of the synthetic wrapper,
|
|
// orphaning the strays so they never draw.
|
|
var window = layout.FindElement(RootId) ?? layout.Root;
|
|
var c = new ChatWindowController { Root = window };
|
|
|
|
// Drop the dat top resize bar (0x1000000F): it is authored 800px wide and
|
|
// juts out of the content-width window. The host wraps this content in the
|
|
// universal nine-slice chrome, whose grips provide the resize affordance.
|
|
if (layout.FindElement(ResizeBarId) is { Parent: { } rbParent } resizeBar)
|
|
rbParent.RemoveChild(resizeBar);
|
|
|
|
// Reclaim the 9px strip the dropped resize bar occupied (rows 0-8 of the root):
|
|
// grow the transcript panel up to the window top so its dark bg fills the strip.
|
|
// Otherwise the root element's brown bg shows through as a sliver along the top.
|
|
transcriptPanel.Top = 0f;
|
|
transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9)
|
|
|
|
// ── Transcript ───────────────────────────────────────────────────
|
|
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
|
|
// Find it in the widget tree and bind the live providers — no remove/add needed.
|
|
c.Transcript = layout.FindElement(TranscriptId) as UiText
|
|
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
|
c.Transcript.DatFont = datFont;
|
|
c.Transcript.Font = debugFont;
|
|
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
|
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
|
|
|
// ── Input ────────────────────────────────────────────────────────
|
|
// Place the behavioral input widget inside the input bar.
|
|
c.Input = new UiChatInput
|
|
{
|
|
Left = iInfo.X,
|
|
Top = iInfo.Y,
|
|
Width = iInfo.Width,
|
|
Height = iInfo.Height,
|
|
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
|
|
DatFont = datFont,
|
|
Font = debugFont,
|
|
SpriteResolve = resolve,
|
|
FocusFieldSprite = InputFocusField,
|
|
};
|
|
inputBar.AddChild(c.Input);
|
|
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
|
|
|
// ── Scrollbar — bind the factory-built Type-11 track element ────────
|
|
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
|
|
// directly. Find it, bind it in place — no remove/add needed.
|
|
var track = layout.FindElement(TrackId);
|
|
if (track is UiScrollbar bar)
|
|
{
|
|
float oldTop = bar.Top;
|
|
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
|
|
bar.Height = bar.Height + oldTop;
|
|
bar.Model = c.Transcript.Scroll;
|
|
bar.SpriteResolve = resolve;
|
|
bar.TrackSprite = TrackSprite;
|
|
bar.ThumbSprite = ThumbSprite;
|
|
bar.ThumbTopSprite = ThumbTopSprite;
|
|
bar.ThumbBotSprite = ThumbBotSprite;
|
|
bar.UpSprite = UpSprite;
|
|
bar.DownSprite = DownSprite;
|
|
c.Scrollbar = bar;
|
|
}
|
|
|
|
// ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
|
|
if (layout.FindElement(MenuId) is UiMenu menu)
|
|
{
|
|
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
|
|
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
|
|
menu.PopupBgSprite = MenuPopupBg;
|
|
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
|
|
menu.Items = System.Array.ConvertAll(ChannelItems,
|
|
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
|
|
menu.Selected = (object?)c._activeChannel;
|
|
// Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like
|
|
// retail; only the talk-CHANNEL items grey when unavailable.
|
|
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
|
|
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
|
|
// The widget reports the pick; the controller owns Selected. Only a talk-channel
|
|
// payload updates the active channel + highlight — the null-payload specials are
|
|
// deferred no-ops (see the chat re-drive deferred list) and leave selection intact.
|
|
menu.OnSelect = p =>
|
|
{
|
|
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
|
|
};
|
|
c.Menu = menu;
|
|
}
|
|
|
|
// ── Send button — Enter-alternate submit trigger ──────────────────
|
|
// Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
|
|
if (layout.FindElement(SendId) is UiButton sendEl)
|
|
{
|
|
sendEl.OnClick = () => c.Input.Submit();
|
|
// The Send sprite is a blank gold button — retail draws the caption as text.
|
|
sendEl.Label = "Send";
|
|
sendEl.LabelFont = datFont;
|
|
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
|
}
|
|
|
|
// ── Size the channel button to its label + reflow the input field ─
|
|
// Retail's talk-focus button autosizes to the selected channel name; the input
|
|
// field then fills the gap from the button's right edge to the Send button. The
|
|
// dat authors the button at a fixed 46px (too narrow for "Chat" once the LED +
|
|
// arrow are accounted for), so widen it to its content and shift the input.
|
|
// Recompute on every channel change (the button grows/shrinks with the label).
|
|
if (c.Menu is not null)
|
|
{
|
|
float inputRight = c.Input.Left + c.Input.Width; // == Send button's left edge
|
|
void ReflowInputRow()
|
|
{
|
|
c.Menu.Width = System.MathF.Round(c.Menu.NaturalButtonWidth());
|
|
c.Menu.ResetAnchorCapture();
|
|
c.Input.Left = c.Menu.Left + c.Menu.Width;
|
|
c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left);
|
|
c.Input.ResetAnchorCapture();
|
|
}
|
|
var onSelect = c.Menu.OnSelect;
|
|
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
|
|
ReflowInputRow();
|
|
}
|
|
|
|
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
|
|
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
|
|
{
|
|
// The dat puts max/min and the scrollbar up-button at the SAME X (both
|
|
// right-anchored), so at content width they overlap. Retail shows max/min
|
|
// just LEFT of the scrollbar column — shift it one button-width left.
|
|
if (track is not null)
|
|
maxMinEl.Left = track.Left - maxMinEl.Width;
|
|
maxMinEl.OnClick = c.ToggleMaximize;
|
|
}
|
|
|
|
return c;
|
|
}
|
|
|
|
// ── Max/min implementation ─────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Toggle between the normal chat window height and an expanded 320px height.
|
|
/// Simplified port of retail <c>gmMainChatUI::HandleMaximizeButton @0x4cddb0</c>:
|
|
/// retail stores the pre-maximize height and restores it on a second click.
|
|
/// The 320px expanded size is the approximate retail maximized chat height.
|
|
/// </summary>
|
|
private void ToggleMaximize()
|
|
{
|
|
if (!_maximized)
|
|
{
|
|
_normalHeight = Root.Height;
|
|
_normalTop = Root.Top;
|
|
// Expand upward: move the top edge up so the bottom stays anchored.
|
|
Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f);
|
|
Root.Height = 320f;
|
|
_maximized = true;
|
|
}
|
|
else
|
|
{
|
|
Root.Top = _normalTop;
|
|
Root.Height = _normalHeight;
|
|
_maximized = false;
|
|
}
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Depth-first search for an <see cref="ElementInfo"/> node by id in the
|
|
/// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
|
|
/// </summary>
|
|
private static ElementInfo? FindInfo(ElementInfo node, uint id)
|
|
{
|
|
if (node.Id == id) return node;
|
|
foreach (var child in node.Children)
|
|
{
|
|
var found = FindInfo(child, id);
|
|
if (found is not null) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert the ChatVM's detailed lines to the transcript's
|
|
/// <see cref="UiText.Line"/> record format, applying retail-faithful
|
|
/// per-<see cref="ChatKind"/> colors.
|
|
/// </summary>
|
|
private static IReadOnlyList<UiText.Line> BuildLines(
|
|
ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont)
|
|
{
|
|
var detailed = vm.RecentLinesDetailed();
|
|
if (detailed.Count == 0) return Array.Empty<UiText.Line>();
|
|
|
|
// Word-wrap each message to the transcript's current pixel width (ports retail
|
|
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
|
|
// exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize.
|
|
float maxW = view.Width - 2f * view.Padding;
|
|
Func<string, float> measure =
|
|
datFont is { } df ? s => df.MeasureWidth(s)
|
|
: debugFont is { } bf ? s => bf.MeasureWidth(s)
|
|
: s => s.Length * 7f;
|
|
|
|
var result = new List<UiText.Line>(detailed.Count);
|
|
foreach (var d in detailed)
|
|
{
|
|
var color = RetailChatColor(d.Kind);
|
|
foreach (var frag in WrapText(d.Text, maxW, measure))
|
|
result.Add(new UiText.Line(frag, color));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
|
|
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
|
|
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
|
|
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
|
|
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
|
|
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
|
|
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
|
|
/// </summary>
|
|
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
|
|
{
|
|
if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW)
|
|
{
|
|
yield return text ?? string.Empty;
|
|
yield break;
|
|
}
|
|
|
|
var line = new System.Text.StringBuilder();
|
|
foreach (var word in text.Split(' '))
|
|
{
|
|
string sep = line.Length > 0 ? " " : string.Empty;
|
|
if (measure(line.ToString() + sep + word) <= maxW)
|
|
{
|
|
line.Append(sep).Append(word); // fits on the current line
|
|
continue;
|
|
}
|
|
if (line.Length > 0 && measure(word) <= maxW)
|
|
{
|
|
yield return line.ToString(); // word fits alone → push to a new line
|
|
line.Clear();
|
|
line.Append(word);
|
|
continue;
|
|
}
|
|
// Word too long for any single line: char-wrap it, packing onto the current
|
|
// line's remaining space first (keeps the prefix with the message start).
|
|
if (line.Length > 0) line.Append(' ');
|
|
foreach (char ch in word)
|
|
{
|
|
if (line.Length > 0 && measure(line.ToString() + ch) > maxW)
|
|
{
|
|
yield return line.ToString();
|
|
line.Clear();
|
|
}
|
|
line.Append(ch);
|
|
}
|
|
}
|
|
if (line.Length > 0) yield return line.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-<see cref="ChatKind"/> text color — the EXACT retail RGBA values read from a
|
|
/// live retail client via cdb (the named <c>RGBAColor</c> constants at acclient
|
|
/// 0x81c4a8+, e.g. <c>colorWhite</c>/<c>colorBrightPurple</c>/<c>colorLightBlue</c>/
|
|
/// <c>colorGreen</c>, used by <c>ChatInterface::BuildChatColorLookupTable @0x4f31c0</c>).
|
|
/// The four common kinds (speech/tell/channel/system) are confirmed by the named
|
|
/// symbols + universal AC convention; the rarer kinds map to the nearest named color.
|
|
/// </summary>
|
|
private static Vector4 RetailChatColor(ChatKind kind) => kind switch
|
|
{
|
|
ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // colorWhite
|
|
ChatKind.RangedSpeech => new(1f, 1f, 1f, 1f), // colorWhite (shout)
|
|
ChatKind.Channel => new(0.247f, 0.749f, 1f, 1f), // colorLightBlue
|
|
ChatKind.Tell => new(1f, 0.498f, 1f, 1f), // colorBrightPurple
|
|
ChatKind.System => new(0.5f, 1f, 0.498f, 1f), // colorGreen
|
|
ChatKind.Popup => new(0.5f, 1f, 0.498f, 1f), // colorGreen (server broadcast)
|
|
ChatKind.Emote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey
|
|
ChatKind.SoulEmote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey
|
|
ChatKind.Combat => new(0.96f, 0.459f, 0.447f, 1f), // colorLightRed
|
|
_ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback)
|
|
};
|
|
}
|