feat(input-preview): full R1 migration (PreOpenCheck/PreDraw split + Lender)

I resurrects InputPreview as a fully ctor-injected window:

- Class header public → internal sealed (Components-Layer style); Plugin.cs
  property visibility corrected to internal to match
- Ctor takes 5 DI deps (ChunkRenderer, Lender<PayloadHandler>, MainWindow,
  InputBar, ILogger) via Factory-Lambda DI-reg
- Window-hook split: PreOpenCheck() owns the state (Drawing/PreviewMessage/
  HasEvaluation/PreviewHeight/LastLength), PreDraw() owns position/size
  computation. Matches v1.5.6's split — avoids wasted position-math when
  Window isn't drawn (DrawConditions gates on IsDrawable getter).
- Framework.Update subscribe/unsubscribe removed (PreOpenCheck runs per
  draw-frame, same cadence as Framework.Update for our needs)
- Draw() borrows fresh PayloadHandler per-frame from Lender for popup
  isolation (preview hover doesn't bleed into log)
- Defensive ResetCounter fallback when MainWindow closed + InputPreview
  open — primary path is A2's MainWindow.Draw() ResetCounter

R2/R3 (J/K) next. A2 closes out the Lender DI-cycle for MainWindow.
This commit is contained in:
2026-05-27 20:35:25 +02:00
parent 931a152d00
commit 8431fbcf80
3 changed files with 177 additions and 13 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
internal Ui.Windows.MainWindow MainWindow { get; private set; } = null!; internal Ui.Windows.MainWindow MainWindow { get; private set; } = null!;
internal Ui.Windows.SettingsWindow SettingsWindow { get; private set; } = null!; internal Ui.Windows.SettingsWindow SettingsWindow { get; private set; } = null!;
public DbViewer DbViewer { get; private set; } = null!; public DbViewer DbViewer { get; private set; } = null!;
public InputPreview InputPreview { get; private set; } = null!; internal InputPreview InputPreview { get; private set; } = null!;
public CommandHelpWindow CommandHelpWindow { get; private set; } = null!; public CommandHelpWindow CommandHelpWindow { get; private set; } = null!;
public SeStringDebugger SeStringDebugger { get; private set; } = null!; public SeStringDebugger SeStringDebugger { get; private set; } = null!;
public FirstRunWizard FirstRunWizard { get; private set; } = null!; public FirstRunWizard FirstRunWizard { get; private set; } = null!;
+7 -1
View File
@@ -278,7 +278,13 @@ internal static class PluginHostFactory
sp.GetRequiredService<Plugin>(), sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<ILogger<DbViewer>>() sp.GetRequiredService<ILogger<DbViewer>>()
)); ));
services.AddSingleton(sp => new InputPreview(sp.GetRequiredService<Plugin>())); services.AddSingleton(sp => new InputPreview(
sp.GetRequiredService<Ui.Components.ChunkRenderer>(),
sp.GetRequiredService<Lender<PayloadHandler>>(),
sp.GetRequiredService<Ui.Windows.MainWindow>(),
sp.GetRequiredService<Ui.Components.InputBar>(),
sp.GetRequiredService<ILogger<InputPreview>>()
));
services.AddSingleton(sp => new CommandHelpWindow(sp.GetRequiredService<Plugin>())); services.AddSingleton(sp => new CommandHelpWindow(sp.GetRequiredService<Plugin>()));
services.AddSingleton(sp => new SeStringDebugger(sp.GetRequiredService<Plugin>())); services.AddSingleton(sp => new SeStringDebugger(sp.GetRequiredService<Plugin>()));
services.AddSingleton(sp => new DebuggerWindow(sp.GetRequiredService<Plugin>())); services.AddSingleton(sp => new DebuggerWindow(sp.GetRequiredService<Plugin>()));
+169 -11
View File
@@ -1,20 +1,50 @@
using System.Numerics;
using System.Text;
using System.Text.RegularExpressions;
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing; using Dalamud.Interface.Windowing;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
using Microsoft.Extensions.Logging;
namespace HellionChat.Ui; namespace HellionChat.Ui;
// Pre-send chunk preview is offline while the chat input pipeline is internal sealed partial class InputPreview : Window
// rebuilt. The window stays in the system so the DI graph keeps a single
// shape across cycles, but DrawConditions always returns false until the
// new preview lands on top of the components layer.
public class InputPreview : Window
{ {
private readonly Plugin _plugin; private readonly Components.ChunkRenderer _chunkRenderer;
private readonly Lender<PayloadHandler> _handlerLender;
private readonly Windows.MainWindow _mainWindow;
private readonly Components.InputBar _inputBar;
private readonly ILogger<InputPreview> _logger;
internal InputPreview(Plugin plugin) private bool _drawing;
private bool _hasEvaluation;
internal float PreviewHeight;
private int _lastLength;
private Message? _previewMessage;
internal int SelectedCursorPos = -1;
public InputPreview(
Components.ChunkRenderer chunkRenderer,
Lender<PayloadHandler> handlerLender,
Windows.MainWindow mainWindow,
Components.InputBar inputBar,
ILogger<InputPreview> logger
)
: base("##chat2-inputpreview") : base("##chat2-inputpreview")
{ {
_plugin = plugin; _chunkRenderer = chunkRenderer;
_handlerLender = handlerLender;
_mainWindow = mainWindow;
_inputBar = inputBar;
_logger = logger;
Flags = Flags =
ImGuiWindowFlags.NoSavedSettings ImGuiWindowFlags.NoSavedSettings
| ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoTitleBar
@@ -22,14 +52,142 @@ public class InputPreview : Window
| ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoResize
| ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoFocusOnAppearing
| ImGuiWindowFlags.NoScrollbar; | ImGuiWindowFlags.NoScrollbar;
RespectCloseHotkey = false; RespectCloseHotkey = false;
DisableWindowSounds = true; DisableWindowSounds = true;
IsOpen = false; IsOpen = true;
// Logger injected for future diagnostic hooks (no call-sites yet in R1).
_ = _logger;
} }
public void Dispose() { } public void Dispose() { }
public override bool DrawConditions() => false; private bool ValidDraw =>
!string.IsNullOrEmpty(_inputBar.PendingMessage)
&& _inputBar.PendingMessage.Length >= Plugin.Config.PreviewMinimum;
public override void Draw() { } // IsDrawable gates DrawConditions; it is also consumed externally by
// any component that needs to know whether the preview popup is visible.
internal bool IsDrawable => ValidDraw && _hasEvaluation;
private static bool IsWindowMode =>
Plugin.Config.PreviewPosition is PreviewPosition.Top or PreviewPosition.Bottom;
// PreOpenCheck owns state: it runs once per frame before the visibility
// gate so Drawing/PreviewMessage/HasEvaluation stay fresh even when the
// window is not ultimately drawn. PreDraw owns position/size to avoid
// wasted computation on frames where DrawConditions returns false
// (position math only matters when the window is about to render).
// This matches the v1.5.6 UpdateConditionCheck/PreDraw split — the
// Framework.Update subscribe is removed; PreOpenCheck runs at the same
// cadence via Dalamud's WindowSystem.
public override void PreOpenCheck()
{
_drawing = ValidDraw;
if (!_drawing)
{
_lastLength = 0;
PreviewHeight = 0;
_previewMessage = null;
_hasEvaluation = false;
return;
}
if (_previewMessage == null || _lastLength != _inputBar.PendingMessage.Length)
{
_lastLength = _inputBar.PendingMessage.Length;
var bytes = Encoding.UTF8.GetBytes(_inputBar.PendingMessage.Trim());
AutoTranslate.ReplaceWithPayload(ref bytes);
var chunks = ChunkUtil
.ToChunks(SeString.Parse(bytes), ChunkSource.Content, ChatType.Say)
.ToList();
_previewMessage = Message.FakeMessage(chunks, new ChatCode(XivChatType.Say, 0, 0));
_previewMessage.DecodeTextParam();
}
_hasEvaluation = !Plugin.Config.OnlyPreviewIf || _previewMessage.Content.Count > 1;
}
public override bool DrawConditions()
{
return IsWindowMode && IsDrawable;
}
public override void PreDraw()
{
var pos = _mainWindow.LastWindowPos;
var size = _mainWindow.LastWindowSize;
Size = size with { Y = PreviewHeight };
var y = Plugin.Config.PreviewPosition switch
{
PreviewPosition.Top => pos.Y - PreviewHeight,
PreviewPosition.Bottom => pos.Y + size.Y,
_ => throw new ArgumentOutOfRangeException(
nameof(Plugin.Config.PreviewPosition),
Plugin.Config.PreviewPosition,
null
),
};
Position = pos with { Y = y };
PositionCondition = ImGuiCond.Always;
}
public override void Draw()
{
CalculatePreviewHeight();
DrawPreview();
}
private void CalculatePreviewHeight()
{
// Pre-draw offscreen once to measure actual rendered height; value is
// consumed next frame by PreDraw() for window sizing.
PreviewHeight = 0;
var pos = ImGui.GetCursorPos();
ImGui.SetCursorPos(new Vector2(-500, -500));
var before = ImGui.GetCursorPosY();
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
{
ImGui.TextUnformatted(Language.Options_Preview_Header);
_chunkRenderer.DrawChunks(_previewMessage!.Content, wrap: true, lineWidth: 0f);
}
var after = ImGui.GetCursorPosY();
ImGui.SetCursorPos(pos);
PreviewHeight = after - before;
PreviewHeight += IsWindowMode ? ImGui.GetStyle().WindowPadding.Y * 2 : 0;
}
private void DrawPreview()
{
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
{
ImGui.TextUnformatted(Language.Options_Preview_Header);
// Primary path (A2) resets the Lender counter in MainWindow.Draw();
// this fallback covers the edge-case where MainWindow is closed but
// InputPreview is still open, preventing handler pool growth.
if (!_mainWindow.IsOpen)
_handlerLender.ResetCounter();
var handler = _handlerLender.Borrow();
_chunkRenderer.DrawChunks(
_previewMessage!.Content,
wrap: true,
handler: handler,
lineWidth: 0f
);
handler.Draw();
}
}
[GeneratedRegex(@"(\s)")]
private static partial Regex WhitespaceRegex();
} }