fix(tells): a tell no longer takes the keyboard mid-sentence

Reported by Carla: a tell arriving while you are typing pulls the focus
away. The interruption is the visible half. The sharp half is that the
input buffer belongs to the WINDOW while the send target is read off
whatever tab is active at Enter -- so a line typed at one person could
leave addressed to whoever just wrote, and in this game losing the
keyboard means the next sentence walks the character around.

Nothing is revealed now while any chat surface is mid-sentence, in any
mode. The tab still appears and still carries its unread mark. The check
lives in its own file because the answer has to be identical everywhere:
it started inside the reveal plan, and a second pop-out path walked
straight past it -- AutoTellTabsService opened windows off its own flag,
at tab creation, a tick before the router was ever asked.

Those two paths are one now. AutoTellTabsOpenAsPopout and TellAutoOpenMode
were two settings for one decision, and the older one won every race,
which is why the other looked inert. Config schema 28 carries the old flag
forward so nobody's behaviour changes. "Off" went with it: it never
stopped the tab from being created -- that is the auto-tell switch -- it
only stopped the jump to it, which is what the switch below it does.

Also in here, all from the same corner of the code:

- Closing a tab was lost in the v2.0.0 rebuild. The trash entry lived in
  the retired ChatLogWindow menu, and the rebuilt one restored rename,
  sound, pop-out and pinning but not this. For tell tabs that left no way
  out at all: IsEditable keeps them out of the settings editor on purpose
  and points at the context menu, which could not close them either.
  Pinned tell tabs stay disabled with a tooltip rather than absent.
- Re-anchoring the active tab used an unconditional Tabs[0] in three
  places, and Tabs[0] can be popped out -- so it ran OnTabActivated over a
  tab live in its own window and stripped its tell binding. With every tab
  popped, the seed and the re-anchor also fought each other every frame.
- PinTab_LimitReached still pointed at "Promote to permanent", removed in
  May. Spanish said "Desija", which is not a word; Greek left "tell tabs"
  untranslated; pt-PT broke its own unpin verb.
- Pop Out was a hardcoded English literal despite the key existing in all
  25 languages since v1.5.6, and the tell-open modes were the last English
  display names in the plugin.
- Segmented setting rows measured 200px flat, which cut German labels in
  half. They size to their longest label now.
- Metrics.Scale still called GlobalScaleSafe. It is an alias for
  GlobalScale in current Dalamud, and dropping it clears the last compiler
  warning in the project.
This commit is contained in:
2026-08-23 02:12:27 +02:00
parent 522545c7a2
commit eca566321a
42 changed files with 882 additions and 284 deletions
@@ -203,6 +203,10 @@ internal sealed class SettingsWidgets
// One setting, n choices. The control fills the whole control column rather
// than right-aligning, because segments need the room to stay readable.
// Breathing room left and right of a segment's label. Chosen against the
// control's own Inset/Chamfer so the chamfered corner never bites into text.
private const float SegmentLabelPadX = 12f;
internal void SegmentRow<T>(
uint id,
string label,
@@ -224,6 +228,29 @@ internal sealed class SettingsWidgets
var picked = selected;
var colors = _segmented;
// Measured, not the 200px default: the control splits its width evenly
// across the segments, so the longest label in the CURRENT language sets
// what fits. Three short English words fit the default; two long German
// ones did not, and came out as "op-Out-Fenste". Which language is the
// longest is not knowable up front -- 25 of them ship.
//
// Only a WISH: SettingRowSplit hands the label column its MinLabelWidth
// first, so an extreme translation cannot push the label off the row.
var widest = 0f;
foreach (var text in labels)
widest = MathF.Max(widest, ImGui.CalcTextSize(text).X);
// CalcTextSize is already scaled, PreferredControlWidth is scaled again
// downstream -- back to design pixels before handing it over.
var scale = StyleEngine.Metrics.Scale;
var style = new SettingRowStyle
{
PreferredControlWidth = MathF.Max(
200f,
(widest / scale + SegmentLabelPadX * 2f) * labels.Length
),
};
SettingRow.Draw(
id,
label,
@@ -233,7 +260,8 @@ internal sealed class SettingsWidgets
{
ImGui.SetCursorScreenPos(new Vector2(ctx.ControlOrigin.X, ctx.ControlOrigin.Y));
picked = SegmentedControl.Draw(id, ctx.ControlWidth, labels, selected, colors);
}
},
styleOverride: style
);
if (picked == selected)
@@ -1,6 +1,8 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Resources;
using HellionChat.Ui.StyleEngine;
using HellionChat.Ui.StyleEngine.Widgets;
namespace HellionChat.Ui.Components.Settings.Tabs;
@@ -8,11 +10,31 @@ internal sealed class ChannelsTab
{
private readonly SettingsWidgets _w;
private readonly TabEditor _editor;
private readonly WidgetPalette _palette;
// Three offered values for a four-value enum. TopTab stays in the enum so no
// saved config needs migrating, but it no longer differs from Sidebar: the
// difference used to be that the router rewrote the window layout as a side
// effect, which is exactly what got removed. Both read back as Main window.
private static readonly TellAutoOpenMode[] TellOpenValues =
[
TellAutoOpenMode.Sidebar,
TellAutoOpenMode.Popout,
];
// Built per call, not cached: a runtime language switch has to reach these
// (same reason as WindowTab.LayoutLabels).
private static string[] TellOpenLabels =>
[
HellionStrings.Settings_Channels_TellAutoOpen_MainWindow,
HellionStrings.Settings_Channels_TellAutoOpen_Popout,
];
public ChannelsTab(Plugin plugin, TokenResolver resolver)
{
_w = new SettingsWidgets(plugin, new SettingsPalette(resolver));
_editor = new TabEditor(plugin);
_palette = new WidgetPalette(resolver);
}
public void Draw()
@@ -68,13 +90,6 @@ internal sealed class ChannelsTab
() => Plugin.Config.AutoTellTabsShowGreetedToggle,
v => Plugin.Config.AutoTellTabsShowGreetedToggle = v
);
_w.ToggleRow(
ImGui.GetID("channels.autotell.popout"u8),
HellionStrings.ChatLog_AutoTellTabs_OpenAsPopout_Name,
HellionStrings.ChatLog_AutoTellTabs_OpenAsPopout_Description,
() => Plugin.Config.AutoTellTabsOpenAsPopout,
v => Plugin.Config.AutoTellTabsOpenAsPopout = v
);
// Written for this screen and never shown until this cycle. It names
// the one setting in a third-party plugin that silently stops
@@ -91,14 +106,45 @@ internal sealed class ChannelsTab
)
)
{
_w.EnumComboRow(
_w.SegmentRow(
ImGui.GetID("channels.autoopen.mode"u8),
HellionStrings.Settings_Channels_TellAutoOpenMode_Name,
HellionStrings.Settings_Channels_TellAutoOpenMode_Description,
() => Plugin.Config.TellAutoOpenMode,
v => Plugin.Config.TellAutoOpenMode = v,
v => v.Name()
TellOpenValues,
TellOpenLabels,
// TopTab and Off are not in the offered set, and SegmentRow falls
// back to index 0 on an unknown value. The v28 migration converts
// both, so this only catches a config that skipped it.
() =>
Plugin.Config.TellAutoOpenMode == TellAutoOpenMode.Popout
? TellAutoOpenMode.Popout
: TellAutoOpenMode.Sidebar,
v => Plugin.Config.TellAutoOpenMode = v
);
// Only under Pop-out, because it is the only setting the guard can
// visibly contradict: you picked "own window" and sometimes get a tab
// instead. Said in the theme's danger colour rather than a raw red so
// it stays legible on every palette.
if (Plugin.Config.TellAutoOpenMode == TellAutoOpenMode.Popout)
{
ImGui.Spacing();
// Scoped block, not `using var`: that pops at the END OF THE
// METHOD, and every section drawn below this one would come out
// in the danger colour.
using (
ImRaii.PushColor(
ImGuiCol.Text,
_palette.Abgr(
Token.StatusDanger,
Plugin.Instance.ThemeRegistry.Active.Colors
)
)
)
{
ImGui.TextWrapped(HellionStrings.Settings_Channels_TellAutoOpen_BusyHint);
}
}
_w.ToggleRow(
ImGui.GetID("channels.autoopen.switch"u8),
HellionStrings.Settings_Channels_TellSwitchAlways_Name,
+13 -2
View File
@@ -190,7 +190,17 @@ internal sealed class Sidebar
unpinnedHeaderRendered = true;
}
DrawRow(tab, expanded, accentRgba, textAbgr, mutedAbgr, dimAbgr, dl, ref activeTab);
DrawRow(
tab,
tabs,
expanded,
accentRgba,
textAbgr,
mutedAbgr,
dimAbgr,
dl,
ref activeTab
);
}
}
@@ -215,6 +225,7 @@ internal sealed class Sidebar
private void DrawRow(
Tab tab,
IReadOnlyList<Tab> tabs,
bool expanded,
uint accentRgba,
uint textAbgr,
@@ -454,7 +465,7 @@ internal sealed class Sidebar
LastRenderedUnreadDotCount++;
}
TabContextMenu.Draw(tab, "ctx", _pool);
TabContextMenu.Draw(tab, "ctx", _pool, tabs);
if (hasPopOut)
{
+77 -6
View File
@@ -24,7 +24,12 @@ internal static class TabContextMenu
// popup; the open trigger is a right-click on the LAST submitted item
// (g.LastItemData via IsItemHovered) — any interactive item in between
// would steal the trigger. Only DrawList ops may sit between.
public static void Draw(Tab tab, string popupId, Windows.ChannelPopoutPool pool)
public static void Draw(
Tab tab,
string popupId,
Windows.ChannelPopoutPool pool,
IReadOnlyList<Tab> tabs
)
{
if (!ImGui.BeginPopupContextItem(popupId))
{
@@ -54,13 +59,13 @@ internal static class TabContextMenu
)
)
{
DrawBody(tab, pool);
DrawBody(tab, pool, tabs);
}
ImGui.EndPopup();
}
private static void DrawBody(Tab tab, Windows.ChannelPopoutPool pool)
private static void DrawBody(Tab tab, Windows.ChannelPopoutPool pool, IReadOnlyList<Tab> tabs)
{
// Rename: focus the field the first frame the popup appears.
if (ImGui.IsWindowAppearing())
@@ -95,10 +100,14 @@ internal static class TabContextMenu
if (tab.EnableNotificationSound)
DrawSoundPicker(tab);
if (ImGui.MenuItem("Pop Out"))
if (ImGui.MenuItem(Language.ChatLog_Tabs_PopOut))
pool.TryOpen(tab);
// One separator for the whole lifecycle block below, so a normal tab
// (no pin controls) still gets the rule above its close entry.
ImGui.Separator();
DrawPinControls(tab);
DrawCloseControl(tab, tabs, pool);
}
// Pinning has been complete since v1.4.7 -- pools, cap, persistence, logout
@@ -123,8 +132,6 @@ internal static class TabContextMenu
if (service is null)
return;
ImGui.Separator();
if (tab.IsPinned)
{
if (ImGui.MenuItem(HellionStrings.PinTab_MenuUnpin))
@@ -156,6 +163,70 @@ internal static class TabContextMenu
);
}
// Closing a tab was lost in the v2.0.0 window rebuild: the trash entry lived
// in ChatLogWindow's menu, which cf4705e retired, and the rebuilt menu only
// restored rename, sound, pop-out and pinning.
//
// For tell tabs that left no way out at all. TabLifecycleHelpers.IsEditable
// keeps temp tabs out of the settings editor on purpose and says the context
// menu is where their gestures live -- so the editor was pointing at a menu
// that could not close them either.
//
// Blocked states stay visible and disabled rather than absent: both are
// states the user can undo, and the tooltip is what says how.
private static void DrawCloseControl(
Tab tab,
IReadOnlyList<Tab> tabs,
Windows.ChannelPopoutPool pool
)
{
var closeability = TabLifecycleHelpers.GetCloseability(tab, tabs);
var allowed = closeability == TabLifecycleHelpers.TabCloseability.Allowed;
// A tell tab is closed, a layout tab is deleted. Same gesture, different
// promise: the conversation goes on without its tab, the layout entry does not.
var label = tab.IsTempTab
? HellionStrings.Tabs_Close_MenuItem
: Language.ChatLog_Tabs_Delete;
if (ImGui.MenuItem(label, enabled: allowed) && allowed)
{
CloseTab(tab, pool);
ImGui.CloseCurrentPopup();
return;
}
if (allowed || !ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
return;
ImGuiUtil.Tooltip(
closeability == TabLifecycleHelpers.TabCloseability.BlockedByPin
? HellionStrings.Tabs_Close_UnpinFirst
: HellionStrings.Tabs_Close_LastTab
);
}
// Removal order mirrors TabEditor.Delete, which already does this from a draw
// frame: drop the tab, release the pool slot bound to its identifier, then
// re-anchor the main window if this was the active tab. Safe here because the
// strip iterates a frame snapshot, not the live list, and TryClose only
// releases a fixed slot instead of mutating a window collection.
private static void CloseTab(Tab tab, Windows.ChannelPopoutPool pool)
{
// Draw will never run for this tab again, so a pending rename can no
// longer flush -- and leaving the guard armed would make the NEXT tab's
// Draw see a stale owner. Drop it before the tab goes.
if (_renamingTab == tab.Identifier)
ClearPendingRename();
lock (Plugin.Instance.TabsListLock)
Plugin.Config.Tabs.RemoveAll(t => t.Identifier == tab.Identifier);
pool.TryClose(tab.Identifier);
Plugin.Instance.MainWindow?.ResetActiveTabIfRemoved(tab);
Plugin.Instance.SaveConfig();
}
// The flush depends on Draw running once more for this tab. If it never does —
// LRU eviction, logout, window closed or collapsed, plugin unload, game exit —
// the name only lives in memory until some other SaveConfig happens to run.
+1 -1
View File
@@ -138,7 +138,7 @@ internal sealed class TopTabBar
TabBadge
);
TabContextMenu.Draw(tab, $"toptab_ctx_{tab.Identifier}", _pool);
TabContextMenu.Draw(tab, $"toptab_ctx_{tab.Identifier}", _pool, tabs);
}
LineDivider.Draw(null, borderAbgr, mutedAbgr);
+6 -3
View File
@@ -64,9 +64,12 @@ internal static class Metrics
if (frame == _cachedFrame)
return _cachedScale;
// Safe variant: GlobalScale throws while the interface manager is
// still coming up, and Metrics reaches more call sites than it did.
_cachedScale = ImGuiHelpers.GlobalScaleSafe;
// GlobalScale is what the retired GlobalScaleSafe alias forwarded to;
// it falls back to the Dalamud config scale when ImGui is not up yet.
// That does not make this property pre-init safe on its own -- the
// GetFrameCount above would fault first -- but every Metrics caller
// sits under Ui/ and only runs while drawing.
_cachedScale = ImGuiHelpers.GlobalScale;
_cachedFrame = frame;
return _cachedScale;
}
@@ -106,6 +106,9 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow
// the keybind tail checks when deciding whether to route at this surface.
public bool HasFocusedInput => _input.IsFocused;
// See IFocusableChatWindow.IsInputBusy.
public bool IsInputBusy => _input.IsFocused || _input.PendingLength > 0;
// Arm-and-hold the one-frame Activate flag; the pop-out's Draw applies the
// ImGui focus next frame. Framework-thread safe (field write only).
public void RequestInputFocus()
@@ -10,5 +10,12 @@ internal interface IFocusableChatWindow
{
bool HasFocusedInput { get; }
// "The user is mid-sentence here." Focus alone is not enough: a typed line
// survives a click elsewhere, and stealing the tab out from under it is how
// half a message ends up addressed to whoever just said hello -- the input
// buffer belongs to the WINDOW, but the send target comes from whichever tab
// is active at Enter.
bool IsInputBusy { get; }
void RequestInputFocus();
}
+43 -10
View File
@@ -149,9 +149,19 @@ internal sealed class MainWindow : Window, IFocusableChatWindow
// Framework thread, not the draw frame: needs the current truth, so it takes
// its own lock instead of using the frame snapshot.
//
// Through PickMainActiveTab rather than Tabs[0]: Tabs[0] may be popped out,
// and anchoring on it ran OnTabActivated over a tab that is live in its own
// window -- stripping the tell binding of a conversation the user is in the
// middle of. The next frame's PickMainActiveTab then re-anchored anyway, so
// the strip bought nothing. Passing null asks for the first VISIBLE tab.
Tab? next;
lock (Plugin.Instance.TabsListLock)
next = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null;
next = TabLifecycleHelpers.PickMainActiveTab(
null,
Plugin.Config.Tabs,
t => _pool.IsOpen(t.Identifier)
);
_activeTab = next;
if (next is not null)
TabLifecycleHelpers.OnTabActivated(next, removed);
@@ -243,6 +253,9 @@ internal sealed class MainWindow : Window, IFocusableChatWindow
// input focus before routing a channel-set/REPLY/prefill at it.
public bool HasFocusedInput => _input.IsFocused;
// See IFocusableChatWindow.IsInputBusy.
public bool IsInputBusy => _input.IsFocused || _input.PendingLength > 0;
// 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()
@@ -290,21 +303,41 @@ internal sealed class MainWindow : Window, IFocusableChatWindow
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.
// First-frame seed: the active tab defaults to the first VISIBLE tab so
// the message list isn't empty on a clean session.
//
// Through PickMainActiveTab, not tabs[0]. With every tab popped out this
// seeded tabs[0] and the re-anchor below immediately set it back to null,
// every single frame -- 60 pointless OnTabActivated runs a second, each
// one stripping tell state off a tab that is live in its own window. Null
// here means "nothing to show", which is the state the re-anchor settles
// on anyway, so the oscillation just stops.
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);
var seeded = TabLifecycleHelpers.PickMainActiveTab(
null,
tabs,
t => _pool.IsOpen(t.Identifier)
);
if (seeded is not null)
{
_activeTab = seeded;
// The seeded tab 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;
// the service repair paths never see). Re-seed on the Draw thread --
// same visible-tab rule as the seed above, so this cannot hand the
// window a popped-out tab and strip its tell state on the way.
var reseed = TabLifecycleHelpers.PickMainActiveTab(
null,
tabs,
t => _pool.IsOpen(t.Identifier)
);
_activeTab = reseed;
if (reseed is not null)
TabLifecycleHelpers.OnTabActivated(reseed, active);