Tester feedback, relayed by Flo: the transition from the tab list to the chat field is too hard, and the tabs could present themselves better. Three causes, three changes, no structural touch -- tabs stay tabs, per the standing decision. The full-width border line under every row was a ladder of hard cuts. It is a fading rule now, starting past the icon column and dissolving before the right edge -- the same shape the section headers have used since v1.11.0, at about half the opacity. The gap between the sidebar group and the message area was a bare strip of window background with a hard edge on both sides. A faint surface wash fades across it toward the messages, turning the cut into a seam. And the selected tab bridges that gap: its active fill extends across the spacing so it touches the conversation it selects -- the classic tab metaphor, attached instead of adjacent. The bridge is a RowStyle knob (default zero), so sidebar rows opt in and nothing else inherits it. Hover fills picked up the standard three-pixel rounding on the way.
482 lines
20 KiB
C#
482 lines
20 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);
|
|
}
|
|
|
|
// The gap between the tab list and the conversation used to be a bare
|
|
// strip of window background with a hard edge on both sides -- tester
|
|
// feedback called the transition too hard. A faint surface wash that
|
|
// fades toward the messages turns the cut into a seam. The active row
|
|
// bridges across it (RowStyle.ActiveBridgeWidth), so the selected tab
|
|
// stays attached to its content on top of the wash.
|
|
{
|
|
var seamMin = new Vector2(ImGui.GetItemRectMax().X, ImGui.GetItemRectMin().Y);
|
|
var seamMax = new Vector2(
|
|
seamMin.X + ImGui.GetStyle().ItemSpacing.X,
|
|
ImGui.GetItemRectMax().Y
|
|
);
|
|
var wash = ColourUtil.ApplyAlpha(
|
|
ColourUtil.RgbaToAbgr(Plugin.Instance.ThemeRegistry.Active.Colors.Surface),
|
|
0.35f
|
|
);
|
|
ImGui.GetWindowDrawList().AddRectFilledMultiColor(seamMin, seamMax, wash, 0u, 0u, wash);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|