acdream/src/AcDream.UI.Abstractions/IPanelRenderer.cs
Erik 4c75ced92b feat(ui): chat Copy mode — select + Ctrl+C any text in the chat tail
User reported wanting to mark text in-game and copy it out (item names,
coordinates, NPC dialogue, etc). ImGui doesn't natively let you select
across multiple TextColored widgets, but a read-only multi-line
InputText is fully click-drag selectable + Ctrl+C copyable. This
commit adds a "Copy mode" toggle to ChatPanel that swaps the chat
tail's render path between the colored-line view and a single
selectable text region.

New IPanelRenderer primitive:

  void TextMultilineReadOnly(string id, string content, Vector2 size);

ImGui maps this to InputTextMultiline with the ReadOnly flag — same
selection + Ctrl+C UX a user expects from any text-input widget.
FakePanelRenderer records the call for tests. The future D.2b
custom retail-look backend implements its own equivalent (likely
the same widget pattern with retail font/skin).

ChatPanel rendering:

  · A "Copy mode (select text to Ctrl+C)" Checkbox at the top of
    the panel toggles _copyMode.
  · Off (default) — current per-line render with colored combat
    entries. Visually unchanged from before.
  · On — the chat tail becomes a single TextMultilineReadOnly
    widget holding every visible line joined with newlines. Loses
    per-line color, gains arbitrary-span text selection.
  · Footer (separator + input field) renders identically in both
    modes so the user can still type while in copy mode.

Existing ChatPanelLayoutTests's footer-separator probe was using
IndexOf("Separator") — which now matches the new pre-tail separator
between the Checkbox and the chat tail. Switched to LastIndexOf
which still pins the footer separator (between EndChild and
InputTextSubmit). Behaviour and intent unchanged.

DisplaySettingsTests' With_expression test was still asserting the
old "1920x1080" Default.Resolution; updated to the new "1280x720"
that the previous wire-up commit introduced (the earlier commit
forgot this one).

dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:45:39 +02:00

282 lines
12 KiB
C#

using System.Numerics;
namespace AcDream.UI.Abstractions;
/// <summary>
/// Drawing primitives exposed to panels. The <b>only</b> API panels use to
/// emit pixels. The ImGui backend maps these straight onto ImGui calls; the
/// later custom retail-look backend will map the same primitives onto its
/// own retained-mode toolkit using retail dat-sourced fonts / sprites.
///
/// <para>
/// Keep this interface small and retail-friendly. If a widget requires a
/// feature the custom backend couldn't express with dat assets, don't add
/// it — find a different widget shape that both backends can satisfy.
/// </para>
/// </summary>
public interface IPanelRenderer
{
/// <summary>
/// Begin a top-level window. Matches retail's root <c>UiPanel</c> +
/// ImGui's <c>Begin</c>. Returns <c>false</c> if the window is collapsed
/// — the caller must still call <see cref="End"/> to balance.
/// </summary>
bool Begin(string title);
/// <summary>Close the most recent <see cref="Begin"/>.</summary>
void End();
/// <summary>Draw a single line of text. No formatting / markdown.</summary>
void Text(string text);
/// <summary>Keep the next widget on the same line as the previous one.</summary>
void SameLine();
/// <summary>Horizontal rule separator.</summary>
void Separator();
/// <summary>
/// A filled progress bar.
/// <paramref name="fraction"/> is clamped by the backend to [0, 1].
/// <paramref name="width"/> is the pixel width of the full bar.
/// <paramref name="overlay"/> is optional text (e.g. <c>"54%"</c>) rendered on top.
/// </summary>
void ProgressBar(float fraction, float width, string? overlay = null);
// -- Phase I.1 widget extensions ----------------------------------
/// <summary>
/// Draw a single line of text in the supplied RGBA color.
/// Used for combat-event color-coding (damage red, heal green) and
/// any other surface where a glance distinguishes severity.
/// </summary>
void TextColored(Vector4 rgba, string text);
/// <summary>
/// Open / close section grouping inside a window. Returns
/// <c>true</c> when the section is currently expanded — only render
/// the section's contents in that branch.
/// </summary>
/// <param name="defaultOpen">If the user has never toggled the
/// header before, start in this state.</param>
bool CollapsingHeader(string label, bool defaultOpen = true);
/// <summary>
/// Push an expandable nested node. Pair every successful
/// <c>true</c> return with a matching <see cref="TreePop"/>;
/// when this returns <c>false</c> do NOT call <see cref="TreePop"/>.
/// </summary>
bool TreeNode(string label);
/// <summary>Pop a previously-pushed <see cref="TreeNode"/>.</summary>
void TreePop();
/// <summary>
/// A boolean toggle. Returns <c>true</c> on the frame the user
/// flipped the box (and updates <paramref name="value"/> in place);
/// returns <c>false</c> the rest of the time.
/// </summary>
bool Checkbox(string label, ref bool value);
/// <summary>
/// A clickable button. Returns <c>true</c> on the single frame the
/// user clicked it; <c>false</c> every other frame.
/// </summary>
bool Button(string label);
/// <summary>
/// A drop-down selector. Returns <c>true</c> on the frame the user
/// changed the selection (and updates <paramref name="selectedIndex"/>
/// to the new value); <c>false</c> otherwise.
/// </summary>
bool Combo(string label, ref int selectedIndex, string[] items);
/// <summary>
/// A horizontal slider clamped to <paramref name="min"/> /
/// <paramref name="max"/>. Returns <c>true</c> on frames where the
/// user dragged the value (and updates <paramref name="value"/>);
/// <c>false</c> when idle.
/// </summary>
bool SliderFloat(string label, ref float value, float min, float max);
/// <summary>
/// Time-series line graph (e.g. fps history). The active window is
/// <paramref name="values"/>[<paramref name="offset"/>..
/// <paramref name="offset"/>+<paramref name="count"/>] interpreted
/// as a ring buffer.
/// </summary>
/// <param name="label">Header text shown above the plot.</param>
/// <param name="values">Sample buffer (kept by the caller).</param>
/// <param name="count">Number of valid samples in the ring.</param>
/// <param name="offset">Index of the oldest sample in
/// <paramref name="values"/>.</param>
/// <param name="overlay">Optional centered overlay text (e.g.
/// <c>"60 fps"</c>).</param>
/// <param name="min">Optional fixed lower bound; null = autoscale.</param>
/// <param name="max">Optional fixed upper bound; null = autoscale.</param>
/// <param name="size">Optional pixel size; null = backend default.</param>
void PlotLines(
string label,
float[] values,
int count,
int offset = 0,
string? overlay = null,
float? min = null,
float? max = null,
Vector2? size = null);
/// <summary>
/// Begin a multi-column table. Pair every call with <see cref="EndTable"/>.
/// Inside the table, advance to each cell with
/// <see cref="TableNextColumn"/> before emitting widgets for it.
/// </summary>
void BeginTable(string id, int columns);
/// <summary>Move to the next cell of the active table.</summary>
void TableNextColumn();
/// <summary>Close the most recent <see cref="BeginTable"/>.</summary>
void EndTable();
/// <summary>
/// A single-line text input that submits on Enter. Returns
/// <c>true</c> on the frame the user pressed Enter; in that case
/// <paramref name="submitted"/> is the value entered and the
/// implementation clears <paramref name="buffer"/> for the next
/// frame. On every other frame returns <c>false</c> and
/// <paramref name="submitted"/> is null; the implementation may
/// still mutate <paramref name="buffer"/> as the user types.
/// </summary>
/// <param name="maxLen">Maximum characters the buffer may grow to.</param>
bool InputTextSubmit(string label, ref string buffer, int maxLen, out string? submitted);
/// <summary>Insert a small vertical gap.</summary>
void Spacing();
/// <summary>Reserve invisible space of the given size, useful for
/// layout-only padding.</summary>
void Dummy(Vector2 size);
/// <summary>
/// Draw text that wraps at the current content region width.
/// Use for descriptions, error messages, anything multi-line.
/// </summary>
void TextWrapped(string text);
// -- Phase J Tier 3 — scrollable child region for chat-style layouts --
/// <summary>
/// Open a scrollable nested region inside the current window.
/// <paramref name="size"/> follows ImGui semantics: 0 means "fill
/// available", a positive value is absolute pixels, a negative
/// value means "fill available minus this amount". Pass
/// <c>(0, -footerHeight)</c> to reserve space at the bottom for
/// fixed elements (separator + input field) so the inner content
/// scrolls but the footer stays put across window resizes.
/// </summary>
bool BeginChild(string id, Vector2 size, bool border = false);
/// <summary>Close the most recent <see cref="BeginChild"/>.</summary>
void EndChild();
/// <summary>
/// Approximate single-line widget height including ImGui's frame
/// padding + item spacing. Panels use this to compute footer
/// reservations for <see cref="BeginChild"/> (e.g. one input
/// field + spacing). Backend-friendly because it returns a
/// scalar that the custom retail-look toolkit can also expose.
/// </summary>
float FrameHeightWithSpacing();
/// <summary>
/// Scroll the current scroll region so that the given fractional
/// vertical position is visible, where 0 = top and 1 = bottom.
/// Typical usage: call with <c>1.0f</c> at the bottom of a
/// chat-tail render block to keep the latest line visible when
/// new entries arrive. No-op when no new content was emitted —
/// callers are expected to skip the call when they don't want
/// to force a scroll.
/// </summary>
void SetScrollHereY(float ratio);
/// <summary>
/// Request keyboard focus for the NEXT widget rendered. Used by
/// <c>ChatPanel</c> when Tab fires <c>ToggleChatEntry</c> — the
/// chat input gets focused programmatically so the user can begin
/// typing without clicking the field.
/// </summary>
void SetKeyboardFocusHere();
// -- Phase K.3 — top-of-screen main menu bar -------------------------
/// <summary>
/// Open the top-of-screen main menu bar. Returns true if the bar is
/// visible (top-level menus go inside that branch). Always pair with
/// <see cref="EndMainMenuBar"/> when the call returned true.
/// </summary>
bool BeginMainMenuBar();
/// <summary>Close the menu bar opened by <see cref="BeginMainMenuBar"/>.</summary>
void EndMainMenuBar();
/// <summary>
/// Open a top-level menu within a menu bar. Returns true if the menu
/// is open — the caller emits <see cref="MenuItem"/> entries inside
/// that branch, then calls <see cref="EndMenu"/>.
/// </summary>
bool BeginMenu(string label);
/// <summary>Close the menu opened by <see cref="BeginMenu"/>.</summary>
void EndMenu();
/// <summary>
/// A clickable menu item with optional shortcut hint (e.g.
/// <c>"F11"</c>) drawn right-aligned. Returns true on the single
/// frame the user clicks the item; false otherwise.
/// </summary>
bool MenuItem(string label, string? shortcut = null);
// -- Tab bar (Settings panel + future tabbed surfaces) ---------------
/// <summary>
/// Open a tab bar inside the current window. Returns <c>true</c>
/// when the bar is visible — only emit <see cref="BeginTabItem"/>
/// calls inside that branch. Always pair with
/// <see cref="EndTabBar"/> when the call returned true. Retail had
/// tab bars in the Options UIs (<c>gmGameplayOptionsUI</c> etc), so
/// this primitive must be expressible by the future custom
/// retail-look backend.
/// </summary>
bool BeginTabBar(string id);
/// <summary>Close the tab bar opened by <see cref="BeginTabBar"/>.</summary>
void EndTabBar();
/// <summary>
/// Begin a single tab inside an open <see cref="BeginTabBar"/>.
/// Returns <c>true</c> when the tab is the currently selected one
/// — only render this tab's content in that branch. Always pair
/// with <see cref="EndTabItem"/> when the call returned true.
/// </summary>
bool BeginTabItem(string label);
/// <summary>Close the tab opened by <see cref="BeginTabItem"/>.</summary>
void EndTabItem();
/// <summary>
/// Render a read-only multi-line text region the user can
/// <b>select</b> with click+drag and copy with <c>Ctrl+C</c>.
/// Matches the typical "click into a textbox to grab text" UX —
/// chat panels, log viewers, etc. use this to make text
/// extractable without the user having to alt-tab + retype.
///
/// <para>
/// The widget is sized to <paramref name="size"/>; pass
/// <c>(0, 0)</c> for "fill the current content region" semantics
/// (matches ImGui defaults). <paramref name="id"/> is the ImGui
/// stable identifier — typically <c>"##chatcopy"</c> or similar
/// hidden-label form.
/// </para>
/// </summary>
void TextMultilineReadOnly(string id, string content, Vector2 size);
}