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;
///
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
/// analogue of retail ChatInterface + gmMainChatUI::PostInit @0x4ce130.
///
///
/// The transcript (0x10000011) and input (0x10000016) 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
/// tree (which contains everything) and adds the behavioral
/// widgets as children of their parent container widgets (transcript panel
/// 0x10000010 / input bar 0x10000013) which ARE created as
/// nodes. The scrollbar track (0x10000012) is built
/// directly as a by the factory (Type 11) and is bound in place
/// here. The channel menu (0x10000014) is still replaced with its behavioral counterpart.
///
///
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 ─────────────────────────────────────────────────────
/// Root element of the imported layout (the chat window chrome).
public UiElement Root { get; private set; } = null!;
/// Live chat transcript widget. Null until succeeds.
public UiText Transcript { get; private set; } = null!;
/// Editable chat input widget. Null until succeeds.
public UiChatInput Input { get; private set; } = null!;
/// Scrollbar widget, driven by 's scroll model.
public UiScrollbar Scrollbar { get; private set; } = null!;
/// Channel-selector menu widget.
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;
/// Window height before maximize (stored to restore on un-maximize).
private float _normalHeight;
/// Window top before maximize.
private float _normalTop;
private bool _maximized;
// ── Factory ────────────────────────────────────────────────────────────
///
/// Bind an imported chat layout to live behavior.
///
/// and must come from the
/// SAME pass (ImportInfos then Build)
/// so rects in the info tree match the widget geometry in the layout tree.
///
/// Returns null if the essential transcript/input panels are missing from
/// the info tree or the widget tree (e.g. the layout dat is incomplete).
///
/// Full tree from
/// .
/// Widget tree from .
/// Chat view-model (transcript data + command routing).
/// Factory that returns the live command bus at submit time.
/// Called on every chat submit so it resolves
/// even when the live session is established AFTER runs
/// (mirrors the ImGui ChatPanel which re-reads the bus each frame).
/// Retail dat font for transcript + input rendering.
/// Fallback debug bitmap font (used when
/// is null).
/// Dat RenderSurface id → (GL tex handle, px width, px height).
/// Forwarded to and .
public static ChatWindowController? Bind(
ElementInfo rootInfo,
ImportedLayout layout,
ChatVM vm,
Func busProvider,
UiDatFont? datFont,
BitmapFont? debugFont,
Func 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 ─────────────────────────────────────────────
///
/// Toggle between the normal chat window height and an expanded 320px height.
/// Simplified port of retail gmMainChatUI::HandleMaximizeButton @0x4cddb0:
/// retail stores the pre-maximize height and restores it on a second click.
/// The 320px expanded size is the approximate retail maximized chat height.
///
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 ────────────────────────────────────────────────────────────
///
/// Depth-first search for an node by id in the
/// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
///
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;
}
///
/// Convert the ChatVM's detailed lines to the transcript's
/// record format, applying retail-faithful
/// per- colors.
///
private static IReadOnlyList BuildLines(
ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont)
{
var detailed = vm.RecentLinesDetailed();
if (detailed.Count == 0) return Array.Empty();
// 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 measure =
datFont is { } df ? s => df.MeasureWidth(s)
: debugFont is { } bf ? s => bf.MeasureWidth(s)
: s => s.Length * 7f;
var result = new List(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;
}
///
/// Greedy word-wrap: split into fragments that each fit in
/// pixels (per ), 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).
///
public static IEnumerable WrapText(string text, float maxW, Func 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();
}
///
/// Per- text color — the EXACT retail RGBA values read from a
/// live retail client via cdb (the named RGBAColor constants at acclient
/// 0x81c4a8+, e.g. colorWhite/colorBrightPurple/colorLightBlue/
/// colorGreen, used by ChatInterface::BuildChatColorLookupTable @0x4f31c0).
/// 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.
///
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)
};
}