Files
HellionChat/HellionChat/Ui/Windows/MainWindow.cs
T
JonKazama-Hellion dfc0cda806 fix(chat): four things the header review found, all of them visible
The self-test was the worst of them, because it is the only tool that makes block
A judgeable at all and it destroyed itself on use. It returned Fail while the
atlas was not ready, and its own weight buttons trigger a rebuild -- which is
asynchronous, not synchronous as the comment claimed. Click a weight, watch the
step go red. It waits now, like the two existing steps that had already worked
this out.

The translated stand-in was drawn in the meta face, whose glyph range is ASCII
plus a middle dot. Fifteen of the twenty-five translations reach outside that, so
"not logged in" would have rendered as a row of question marks in Japanese,
Russian, Korean, Greek and eleven others -- and the measured width would have
been the width of the question marks, so the right edge would have drifted too.
The plan said to keep it on the body face and the comment in FontManager says so
as well; the code simply did not. The detail is two parts now rather than one
string, and each part is measured under the face that draws it.

The header was the only place in the UI pushing RegularFont directly, without the
FontsEnabled-or-UseHellionFont check every other push site makes. With both
toggles off the window draws in AXIS and the header would have drawn in
Inter-Light, at a different size, in a band measured against a third one.

And the icon sat on the text baseline. FontAwesome is a fixed-width handle built
at Dalamud's own size and does not follow the plugin's font setting, so at any
other body size it hangs. The sidebar already knew this and centres against the
row; the header does the same now.

Two smaller ones came along: the minimum-height threshold was compared unscaled,
which would have dissolved it at higher display scales, and the height passed
into that check included the input row -- so "is there still room to read" was
measuring the wrong thing. Both callers now say what sits below them.
2026-08-19 10:21:03 +02:00

463 lines
18 KiB
C#

using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using HellionChat.Util;
namespace HellionChat.Ui.Windows;
// Top-level chat window assembled from the components layer. Layout from
// top to bottom: honorific header, horizontal body with sidebar + main
// area (messages + input bar), and the status strip pinned to the
// bottom. The window-level theme push stays on the global plugin draw
// path for now — this window only composes content.
//
// Components are fully qualified through the Ui.Components prefix so the
// old Ui.StatusBar type (still alive until the cleanup block removes it)
// cannot shadow the new layer through parent-namespace resolution.
internal sealed class MainWindow : Window, IFocusableChatWindow
{
private const float DefaultWidth = 620f;
private const float DefaultHeight = 340f;
private const float MinWidth = 480f;
private const float MinHeight = 260f;
private readonly Components.HonorificHeader _honorific;
private readonly Components.Sidebar _sidebar;
private readonly Components.TopTabBar _topTabs;
private readonly Components.MessageList _messages;
private readonly Components.InputBar _input;
private readonly Components.StatusBar _status;
private readonly Lender<PayloadHandler> _handlerLender;
private readonly ChannelPopoutPool _pool;
private readonly Ui.StyleEngine.SurfaceBackdrop _backdrop;
private Tab? _activeTab;
// Runtime-only hide: window stays IsOpen but DrawConditions skips it, so the
// chat-activation key can restore it (1.5.6 HideState.User parity).
private bool _userHidden;
public Vector2 LastWindowPos { get; private set; } = Vector2.Zero;
public Vector2 LastWindowSize { get; private set; } = Vector2.Zero;
internal unsafe ImGuiViewport* LastViewport;
// 1.5.6 viewport-guard input: tracked in Draw, read by PreDraw next frame.
private bool _wasDocked;
public MainWindow(
Components.HonorificHeader honorific,
Components.Sidebar sidebar,
Components.TopTabBar topTabs,
Components.MessageList messages,
Components.InputBar input,
Components.StatusBar status,
Lender<PayloadHandler> handlerLender,
ChannelPopoutPool pool,
Ui.StyleEngine.SurfaceBackdrop backdrop
)
: base($"{Plugin.PluginName}###hellion-main")
{
_honorific = honorific;
_sidebar = sidebar;
_topTabs = topTabs;
_messages = messages;
_input = input;
_status = status;
_handlerLender = handlerLender;
_pool = pool;
_backdrop = backdrop;
Size = new Vector2(DefaultWidth, DefaultHeight);
SizeCondition = ImGuiCond.FirstUseEver;
SizeConstraints = new WindowSizeConstraints
{
MinimumSize = new Vector2(MinWidth, MinHeight),
MaximumSize = new Vector2(float.MaxValue, float.MaxValue),
};
// 1.5.6 parity: the chat always shows on login. The window stays closeable
// and hideable within a session, but that state is not carried across starts.
IsOpen = true;
RespectCloseHotkey = false;
}
// UI-12: per-window focus-dependent opacity. ResolveBgAlpha stays guard-free
// and pure so the self-test can drive it directly; PreDraw owns the guard +
// wiring. 1.5.6 parity (focused → WindowOpacity, unfocused →
// WindowOpacityInactive, ChatLogWindow.PreOpenCheck 1d3b429:724).
internal float ResolveBgAlpha(bool isFocused) =>
isFocused ? Plugin.Config.WindowOpacity : Plugin.Config.WindowOpacityInactive;
// B1-2 / P7: rebuild flags from a fresh base every frame so toggling
// CanMove/CanResize/ShowTitleBar back on actually CLEARS NoMove/NoResize/
// NoTitleBar (not accumulating). Move/resize/title-bar logic as 1.5.6
// (ChatLogWindow.PreOpenCheck 1d3b429:703-710); base flags = today's
// MainWindow set (NoScrollbar|NoScrollWithMouse — the message list owns its
// own scroll; 1.5.6's NoFocusOnAppearing is deliberately not restored).
internal static ImGuiWindowFlags ResolveFlags(bool canMove, bool canResize, bool showTitleBar)
{
var flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse;
if (!canMove)
flags |= ImGuiWindowFlags.NoMove;
if (!canResize)
flags |= ImGuiWindowFlags.NoResize;
if (!showTitleBar)
flags |= ImGuiWindowFlags.NoTitleBar;
return flags;
}
public override void PreDraw()
{
// Dalamud's WindowHost turns Window.BgAlpha into SetNextWindowBgAlpha
// (WindowHost.cs:650-652), which REPLACES this one window's WindowBg
// alpha (imgui.cpp:7229). The global GlobalStyleScope clamp is left
// untouched, so Settings/DbViewer/popouts/wizard keep today's opacity.
// Viewport guard (1.5.6 parity, ChatLogWindow.PreOpenCheck 1d3b429:718):
// only drive BgAlpha while the window is on the main viewport and not
// docked. On a floated own-viewport (Dalamud multi-viewport mode) the
// WindowBg alpha would compose against the OS-layer alpha (double
// transparency), so leave BgAlpha null there and let the global scope
// govern. LastViewport/_wasDocked are last frame's values from Draw
// (one-frame latency, accepted, matches 1.5.6).
unsafe
{
if (LastViewport == ImGuiHelpers.MainViewport.Handle && !_wasDocked)
BgAlpha = ResolveBgAlpha(IsFocused);
else
BgAlpha = null;
}
Flags = ResolveFlags(
Plugin.Config.CanMove,
Plugin.Config.CanResize,
Plugin.Config.ShowTitleBar
);
}
public Tab? ActiveTab => _activeTab;
// Re-anchors the active-tab reference when the tab it points at is removed
// (eviction / logout). Reference compare, so it is immune to the SaveConfig
// temp-tab strip window where a tab is briefly absent from Config.Tabs; the
// re-seeded tab runs through OnTabActivated so a programmatic switch strips
// stale tell state the way a click would.
internal void ResetActiveTabIfRemoved(Tab removed)
{
if (!ReferenceEquals(_activeTab, removed))
return;
// Framework thread, not the draw frame: needs the current truth, so it takes
// its own lock instead of using the frame snapshot.
Tab? next;
lock (Plugin.Instance.TabsListLock)
next = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null;
_activeTab = next;
if (next is not null)
TabLifecycleHelpers.OnTabActivated(next, removed);
}
// Programmatic tab activation for the header quick-picker. Mirrors the click
// path in TopTabBar/Sidebar exactly (previous → set → OnTabActivated) so a
// header pick strips tell-state and resets unread the way a real click does.
internal void ActivateTab(Tab tab)
{
// A popped-out tab is not a surface this window owns. Taking it as
// active does not show it -- PickMainActiveTab re-anchors on the next
// frame, and it anchors to the first non-popped tab, which is not the
// one the user was reading. Callers that mean "bring it forward" have
// to reach for the pool instead.
if (_pool.IsOpen(tab.Identifier))
return;
if (ReferenceEquals(_activeTab, tab))
return;
var previous = _activeTab;
_activeTab = tab;
TabLifecycleHelpers.OnTabActivated(tab, previous);
}
// Tab-cycle entry point for the ChatTabForward/Backward keybinds. Empty list is a
// no-op; a null active tab seeds the index to 0; a single-tab cycle that lands on
// the already-active tab is a no-op (ActivateTab early-returns on the same reference).
// Routes through ActivateTab so the cycle strips stale tell state + re-derives the
// channel exactly like a sidebar/top-tab click. Pop-out focus-forward stays
// deferred (no focus contract) — main-window tabs only.
internal void ChangeTabDelta(int delta)
{
// Runs on Framework.Update via the keybind dispatch, not on the draw frame —
// own lock, own copy. Stays a List so IndexOf below keeps working.
List<Tab> tabs;
lock (Plugin.Instance.TabsListLock)
tabs = Plugin.Config.Tabs.ToList();
if (tabs.Count == 0)
return;
var idx = _activeTab is null ? 0 : tabs.IndexOf(_activeTab);
if (idx < 0)
idx = 0; // active tab not in the list (mid-strip) -> start from the first
var nextIndex = TabLifecycleHelpers.NextMainTabIndex(
idx,
delta,
tabs,
t => _pool.IsOpen(t.Identifier)
);
ActivateTab(tabs[nextIndex]);
}
// Internal accessors for self-tests so the probes can reach the live
// component without exposing them as public surface.
internal Components.Sidebar GetSidebarForSelfTest() => _sidebar;
internal Components.HonorificHeader GetHonorificHeaderForSelfTest() => _honorific;
internal Components.MessageList GetMessageListForSelfTest() => _messages;
internal Components.TopTabBar GetTopTabsForSelfTest() => _topTabs;
public override bool DrawConditions() => !_userHidden;
internal void UserHide() => _userHidden = true;
// Chat-activation keybind (Enter) entry point. Field writes only, so it is safe
// from the framework thread; the draw path applies focus next frame.
internal void ActivateChat()
{
_userHidden = false;
// Also lifts a cutscene hide for the duration of that cutscene. Without
// this the key would appear to do nothing at all during one.
Plugin.Instance.ChatActivationRequested = true;
if (!IsOpen)
{
IsOpen = true;
Plugin.Config.MainWindowOpen = true;
}
BringToFront();
_input.Activate = true;
}
// IFocusableChatWindow — the keybind tail resolves which surface owns the
// input focus before routing a channel-set/REPLY/prefill at it (C3).
public bool HasFocusedInput => _input.IsFocused;
// Arm-and-hold: field writes only, safe from the framework thread; the draw
// path applies the actual ImGui focus next frame (same path as ActivateChat).
public void RequestInputFocus()
{
BringToFront();
_input.Activate = true;
}
// new-shadow on Window.Toggle so the open path also writes Config. A user-hide
// counts as "not visible", so /hellion is a reliable one-press recovery even when
// the Enter keybind can't fire (DirectChat / a focused game text field).
public new void Toggle()
{
var visible = IsOpen && !_userHidden;
IsOpen = !visible;
if (IsOpen)
_userHidden = false;
Plugin.Config.MainWindowOpen = IsOpen;
}
public override void OnClose()
{
Plugin.Config.MainWindowOpen = false;
}
public override void Draw()
{
LastWindowPos = ImGui.GetWindowPos();
LastWindowSize = ImGui.GetWindowSize();
unsafe
{
LastViewport = ImGui.GetWindowViewport().Handle;
}
_wasDocked = ImGui.IsWindowDocked();
// Primary pool-reset path; InputPreview has a defensive fallback for the MainWindow-closed edge case.
_handlerLender.ResetCounter();
// One snapshot for the whole frame. Everything below reads this instead of
// Config.Tabs, so sidebar, top tabs and status bar see the same list even if
// the worker adds or evicts a tab mid-frame. Deliberately a SHALLOW copy:
// tab identity is compared by reference all over the draw path, so cloning
// would break every ReferenceEquals and Contains.
List<Tab> tabs;
lock (Plugin.Instance.TabsListLock)
tabs = Plugin.Config.Tabs.ToList();
// First-frame seed: the active tab defaults to the first persisted
// tab so the message list isn't empty on a clean session.
if (_activeTab is null && tabs.Count > 0)
{
var seeded = tabs[0];
_activeTab = seeded;
// The seeded Tabs[0] is the likeliest legacy stale-tell carrier
// (pre-coupling the detour wrote here); strip it like any activation.
TabLifecycleHelpers.OnTabActivated(seeded, null);
}
else if (_activeTab is { } active && !tabs.Contains(active))
{
// Active tab is no longer in the list (e.g. a wholesale config import
// the service repair paths never see). Re-seed on the Draw thread.
var reseed = tabs.Count > 0 ? tabs[0] : null;
_activeTab = reseed;
if (reseed is not null)
TabLifecycleHelpers.OnTabActivated(reseed, active);
}
// POP-1c: a popped-out tab must not stay the main window's active surface
// (1.5.6 exclusivity). Re-anchor to the first non-popped tab the moment the
// active one is popped; null when every tab is popped (POP-1d guards Draw).
// Runs post-seed, before the sidebar/top-tab draw, so the popped tab never
// renders. Idempotent: PickMainActiveTab returns the same reference once
// settled, so OnTabActivated fires only on the pop frame.
var visibleActive = TabLifecycleHelpers.PickMainActiveTab(
_activeTab,
tabs,
t => _pool.IsOpen(t.Identifier)
);
if (!ReferenceEquals(visibleActive, _activeTab))
{
var previousActive = _activeTab;
_activeTab = visibleActive;
if (visibleActive is not null)
TabLifecycleHelpers.OnTabActivated(visibleActive, previousActive);
}
// The active tab's messages are on screen, so it carries no unread badge
// (1.5.6 convention: zero the current tab every frame so the dot only ever
// shows on tabs you are NOT looking at).
if (_activeTab is { } seenTab)
seenTab.Unread = 0;
var statusHeight = Components.StatusBar.Height;
using (var body = ImRaii.Child("##hellion-body", new Vector2(-1f, -statusHeight)))
{
if (body.Success)
DrawBody(tabs);
}
_status.Draw(_activeTab, tabs);
}
private void DrawBody(IReadOnlyList<Tab> tabs)
{
var bodyWidth = ImGui.GetContentRegionAvail().X;
_honorific.Draw(bodyWidth);
if (Plugin.Config.MainWindowLayoutMode == MainWindowLayoutMode.TopTabs)
{
_topTabs.Draw(tabs, ref _activeTab);
using (ImRaii.Group())
{
DrawMainArea();
}
return;
}
// Sidebar layout (default).
using (ImRaii.Group())
{
_sidebar.Draw(bodyWidth, tabs, ref _activeTab);
}
ImGui.SameLine();
using (ImRaii.Group())
{
DrawMainArea();
}
}
private void DrawMainArea()
{
var inputHeight = Components.InputBar.Height;
// Shrink the message child when Inside-mode preview is active so the
// inline preview block does not overlap the message list. PreviewHeight
// lags one frame behind on the very first keystroke (same as v1.5.6).
var previewHeight =
Plugin.Config.PreviewPosition is PreviewPosition.Inside
&& Plugin.InputPreview.IsDrawable
? Plugin.InputPreview.PreviewHeight
: 0f;
// Before the child, so it does not scroll away with the log. The child's
// height is left alone on purpose: it is given as a negative value, and
// ImGui resolves those against the space still available from the current
// cursor -- which the header has already reduced. Subtracting it a second
// time would open a gap of exactly the header's height above the input row.
if (_activeTab is { } headerTab)
{
var mode =
Plugin.Config.MainWindowLayoutMode == MainWindowLayoutMode.TopTabs
? StyleEngine.Widgets.ChannelHeaderMode.DetailOnly
: StyleEngine.Widgets.ChannelHeaderMode.Full;
// What follows the log in this column, so the header can tell how
// much room the conversation is actually left with. Without it the
// drop-out rule would measure the input row as readable chat.
StyleEngine.Widgets.ChannelHeader.Draw(
headerTab,
mode,
Plugin.Instance.FontManager,
StyleEngine.Widgets.ChannelHeader.CurrentDetail(),
inputHeight + previewHeight
);
}
using (
var messages = ImRaii.Child(
"##hellion-main-area",
new Vector2(-1f, -(inputHeight + previewHeight))
)
)
{
if (messages.Success)
{
// No accent wash, and the motes turned right down. Both work on
// a settings pane, which is read in glances; a chat log is read
// line by line, and anything drifting behind the text competes
// with it. What is left is barely a texture.
_backdrop.Draw(accentWashHeight: 0f, moteIntensity: 0.10f, strength: 0.45f);
if (_activeTab is not null)
_messages.Draw(_activeTab);
}
}
// Inside-mode inline render: measure first so PreviewHeight is fresh
// for the next frame's reservation, then draw between messages and input.
if (
Plugin.Config.PreviewPosition is PreviewPosition.Inside
&& Plugin.InputPreview.IsDrawable
)
{
Plugin.InputPreview.CalculatePreviewHeight();
Plugin.InputPreview.DrawPreview();
}
_input.Draw(_activeTab);
// Tooltip-mode: sampled hover-state from InputBar reflects the actual
// InputText widget (after-Draw IsItemHovered would target a QuickButton).
// ImRaii.Tooltip has no Success guard — BeginTooltip always runs in ctor.
if (
Plugin.Config.PreviewPosition is PreviewPosition.Tooltip
&& Plugin.InputPreview.IsDrawable
&& _input.WasInputTextHovered
)
{
ImGui.SetNextWindowSize(new Vector2(500 * ImGuiHelpers.GlobalScale, -1));
using var tooltip = ImRaii.Tooltip();
Plugin.InputPreview.DrawPreview();
}
}
}