Commit Graph
100 Commits
Author SHA1 Message Date
JonKazama-Hellion 6397916712 fix(settings): show which channels get their history deleted
Three of the four first-run profiles switch the retention sweep on and write a
per-channel deletion policy. The window only ever showed the global default, so
whatever the wizard decided about individual channels was invisible from the
moment the wizard closed.

The tab now lists every channel whose retention differs from the global value,
tagged by where the number comes from, plus a button to drop the custom ones. It
also finally uses the strings written for this screen, sweep description and
default help included.

Read-only, deliberately. Zero means "keep forever" as a global default and
"delete this channel's entire history" as a per-channel value, because the
default takes a separate SQL branch while a mapped channel gets cutoff = now. No
shipped profile contains a zero and no UI could set one, so nothing is broken
today -- but an editor cannot be offered until those two meanings agree. Noted
at the branch in DeleteByRetentionPolicy.

The default slider now goes down to 0, which is the value that means "never
delete anything". Its own label has been promising "0 = never" while the slider
started at 1.
2026-08-18 14:43:34 +02:00
JonKazama-Hellion 588b886537 fix(settings): remove the switches that do nothing
Six settings had a control, a saved value, translated labels, and no reader
anywhere in the plugin. Flipping them changed a byte on disk and nothing else.

Three sat next to each other in the Chat tab under timestamps, so the section
read as four related options where only one -- the 24-hour clock -- works.
FormatTimestamp consults nothing else.

The first-run wizard offered one of them too, on its visual step, and listed it
back in the summary as if it had been applied. That is the first screen a new
user sees, so it goes as well.

Their own strings show how long this has been drifting: the wizard called
PrettierTimestamps "relative time", the settings tab called the same field
"modern layout". Two different features, one boolean, neither implemented. That
is also why these are removed rather than wired up -- there is no single
behaviour to restore, and inventing one belongs in a cycle that plans it.

Config fields stay, with a comment. A stored value should survive until the
rendering it describes exists, and dropping them would silently reset anyone who
set them.

Found by an audit that turned up considerably more of this: 433 of 824
translated resource keys reach no code at all, and the export, cleanup, tab
editor and pin paths are complete but unreachable. That is its own cycle; this
commit only clears what actively lies to the user in the window we are working
on.
2026-08-18 14:41:07 +02:00
JonKazama-Hellion 64db8986e6 fix(style): close the remaining unreserved-space cases
Three more instances of the pattern the previous fix only half caught.

The section header clipped its description and not its title, so a translated
heading wider than the pane ran out over the scrollbar. Only the window clip
rect stopped it, not the content width.

Row's separator used an unscaled 1px offset with a scaled stroke. ImGui strokes
centred on the path, so at UI scale 2 half the line sits below the rectangle the
row reserved -- and rows stack flush, so it landed in the first pixel row of the
next one. Offset now derives from the thickness.

The segmented control claimed in its own comment to share the setting row's
contract. It did not. It took a position as a parameter and then re-pinned the
layout cursor to it, which moves a caller's layout whenever that position was
not the cursor, and it advanced by the full height where the other two subtract
ItemSpacing.Y. It now reads the cursor like they do, and the comment says why
the advance still differs: those two stack flush because they are list entries,
this is a single control and takes the normal gap.

Also: the label centres against the control band when it has no description
below it, since a slider draws its text at FramePadding.Y and a top-aligned
label sits visibly high next to it. Gallery arrays are held rather than rebuilt
per frame, which its own comment already demanded two sections earlier. And
SectionHeader.Reset is gone -- no caller, and section state keys off ids rather
than labels, so nothing needs to clear it on a language change.
2026-08-18 14:38:42 +02:00
JonKazama-Hellion 0c3023c8cb fix(settings): stop enum combos listing blank rows
The shared label buffer was handed to ImGui.Combo whole, with the value count as
the fourth argument. That argument is not the item count -- it is
popupMaxHeightInItems. The count comes from the span's own length, which was
always 8.

So every enum combo listed eight rows. A three-value setting showed five blank
ones below its real entries, and the popup was capped at three rows high, so it
scrolled. Clicking a blank row hit the range guard and silently did nothing.

Five combos were affected: world suffix, name form, command help side, preview
position and tell auto-open mode. The per-tab code this replaced sized its array
to the value count, so the count happened to be right; sharing one buffer is
what exposed the misread parameter.

Slicing the buffer fixes both halves at once: the span carries the real count
and popupMaxHeightInItems falls back to its -1 default.

Also moves Lerp below LerpTowardWhite. It was inserted directly under that
method's comment block, TEST-MIRROR line included, so the documentation sat on
the wrong method.
2026-08-18 14:11:31 +02:00
JonKazama-Hellion c5a0c4d0d7 fix(settings): show the translations the channels tab already had
Twenty resource keys existed, were translated into all 25 languages, and were
reachable from no line of code. The tab drew hardcoded English literals instead,
so a German player read "Enable auto-tell tabs" while the German string sat in
the plugin unused. This is the same defect as the three unreachable settings
earlier in this cycle, one level down: the work was done, the wiring was not.

The literals were also worse than the strings they shadowed. "Enable auto-tell
tabs" against "Automatically open a tab per conversation partner for every
/tell", and every setting had a written description that had never been shown at
all, so nothing in the section explained what it did.

Two of those are worth more than the labels. The conflict hint names the one
setting in another plugin that silently stops auto-tell tabs from ever opening,
which is not something a user works out alone; it is on screen now. The sidebar
width description explains what the 44px default actually means.

The keys carry stale prefixes from the old eight-tab layout -- the preload one
still says Privacy_ though the setting lives here. Renaming them would touch 25
files per key, so they keep their names and the strings go on screen now.
2026-08-18 13:54:01 +02:00
JonKazama-Hellion 1c78a84b9b fix(style): reserve the height a wrapped description actually needs
Both widgets drew their description with a wrap width and then sized the row as
if it were always one line. Same bug, two different symptoms.

A setting row clips to its label column, so the second line was simply cut off,
directly under a comment claiming the text wraps rather than being cut. A
section header has no clip rect at all, so the overflow was drawn over its own
border line and into whatever the caller rendered next.

Both now measure with CalcTextSize against the same wrap width they draw with.
The section header also gained the clip rect it never had, and its lead offset
is computed once instead of twice, so the measured width and the drawn width
cannot drift apart.

Third fix, same family: the segmented control relied on its last InvisibleButton
to leave the cursor in the right place. It happened to work, because the loop
re-pins the cursor before every segment, but a caller cannot see that from the
outside -- and the gallery duly reserved the row a second time with Dummy,
doubling the gap after every segmented control. The widget now owns its advance
explicitly, like the other two, and CalcSize is gone with its only caller.
2026-08-18 13:53:51 +02:00
JonKazama-Hellion f999036e50 refactor(settings): merge the duplicated tab helpers
Six tabs carried a byte-identical DrawToggle, four a byte-identical slider, and
five hand-rolled the same enum combo loop. 281 lines out, 82 in.

The combos were not only duplicated, they were wasteful: each one called
Enum.GetValues inside Draw, so every open settings window allocated five arrays
per frame for sets that cannot change at runtime. EnumValues<T> reads them once
per closed generic, and the label array is one buffer shared by all of them.

Two behaviours are now uniform rather than accidental. The range check on the
selected index existed in exactly one of the five and is now in all of them,
and the tell auto-open combo lost its inline literal array in favour of a Name
extension like its seven peers -- still English, but at least in the place the
localisation pass will look.

SettingsWidgets is constructed by each tab rather than injected. The tabs are DI
singletons and a seventh constructor signature change buys nothing here.

One visible difference: the tell auto-open combo was 220px wide against 200 for
every other combo in the window. It is 200 now.
2026-08-18 13:42:21 +02:00
JonKazama-Hellion fa15130468 feat(style): let a setting row render disabled
Six settings across the window only apply while another one is on. Today they
sit at full contrast and simply do nothing when clicked.

BeginDisabled cannot carry this: it pushes an alpha that ImGui applies inside
its own widgets, and every part of a setting row that a user reads -- label,
description, separator, hover fill -- is draw-list output that never sees it.
So the row fades itself, drops its click, and stops tracking hover.

The flag is passed through to the control callback as well. A draw-list control
handed into the row has exactly the same problem and no other way to learn about
it.

Note the parameter sits before styleOverride, so the one existing positional
call site had to name its argument.
2026-08-18 13:37:43 +02:00
JonKazama-Hellion 688c05cd77 feat(style): add the segmented control
WindowTab picks its layout with two radio buttons that are one setting wearing
two labels. A setting row knows one label and one control, so the choice was
either two rows that misstate the relationship or a control that holds the whole
choice. This is the latter, and it generalises to any small closed set.

Segment bounds come from rounded edges rather than a per-segment width. At 201
pixels over two segments the naive form paints two 100.5px halves that land on
the same physical column, which leaves a seam in the middle and a gap on the
right. Deriving each edge from the run means segment i ends exactly where i+1
starts.

Hover keys are derived per segment for the same reason the toggle derives its
own: the caller has already spent its id on the enclosing row, and sharing it
would tie the row highlight to whichever segment the mouse is over.

Disabled is a parameter rather than a BeginDisabled scope because that alpha
never reaches draw-list output. The invisible buttons are still submitted while
disabled so item count and cursor advance do not change between the two states.

MetricsMath gains Center, which is what CenterY always was underneath. Reusing
CenterY to centre text horizontally would have read as a bug at every call site.
2026-08-18 13:37:43 +02:00
JonKazama-Hellion cade398b4e feat(style): add the collapsible section header widget
Replaces ImGui.CollapsingHeader, whose framed bar is the single most
ImGui-looking element in the settings window -- 25 of them across nine files.

State lives in the widget, not in ImGui's per-window storage. ImGui keys that
off the label, so once the section titles are localised the open/closed state
would reset on every language switch, and two titles that translate to the same
string would share one state. Callers pass a stable key built from an ASCII
literal instead.

The disabled parameter is not optional decoration. BeginDisabled pushes an
alpha that only reaches ImGui's own widgets, so a draw-list header would sit at
full opacity while everything around it fades -- and ThemePicker wraps two of
its headers in exactly that.

Cursor advance goes through ImGuiP.ItemSize like SettingRow, so the scrollbar
sees the full height.

The gallery shows all three states, including the disabled one inside a real
ImRaii.Disabled scope, which is where the alpha problem would otherwise only
surface in the theme picker.
2026-08-18 12:01:12 +02:00
JonKazama-Hellion cbc4b17e1e fix(style): make the setting row usable for the widgets that need it most
Review of the two new widgets found four things that would all have landed on
the first real tab.

SettingRow had no hit area and no return value, so "the whole row is clickable,
label included" -- which is the point of pairing it with a switch -- was not
reachable. The label half is now an InvisibleButton and Draw returns whether it
was clicked.

SetNextItemWidth is a silent no-op for Checkbox, RadioButton and
InvisibleButton: they size themselves from GetFrameHeight and never call
CalcItemWidth. So exactly the controls the settings tabs are full of would have
sat at the left edge of the control column, 200px from where the row promised
to put them. The callback now receives a context with AlignRight for widgets
that know their own size.

The switch and the row shared a HoverState key. An enabled switch would have
kept its row permanently highlighted, and hovering a disabled row would have
slid its knob to "on" -- the widget lying about its own value. ToggleSwitch
derives its animation key now, so a caller cannot collide even by passing the
same id to both.

The cursor advance moves from SetCursorScreenPos to ImGuiP.ItemSize. Both
advance the cursor, but only ItemSize is guaranteed to extend CursorMaxPos,
which is what the scrollbar measures. SetCursorScreenPos happens to do it on
ImGui 1.88, which is what Dalamud ships -- upstream removed that in 1.92 and
asserts on the pattern instead. ItemSize does not touch g.LastItemData, so the
save throttles stay intact.

Two smaller ones: the label column no longer collapses to a single pixel on a
narrow row (it stops at 60 and the control shrinks instead), and the toggle
geometry clamps its radius once rather than deriving travel from an unclamped
value. Descriptions wrap now instead of being cut at the column edge.

The gallery gained the pairing both widgets exist for, plus a style-override
variant -- the combination that would have exposed all of this.
2026-08-18 11:59:52 +02:00
JonKazama-Hellion 86992c70f2 feat(style): add the setting-row and toggle-switch widgets
Two composites for the settings window, both built on the primitives from
v1.10.0 rather than beside them.

SettingRow puts the label left and the control right-aligned. ImGui does it the
other way round, control first and label trailing, which is a large part of why
the settings window reads as a form dump rather than a settings page. The
control arrives as a callback so one row covers toggles, sliders, combos and
buttons.

The cursor advance in SettingRow is the part that needed care. It has to happen
after the callback, because ItemSize overwrites CursorPos outright when the
control is submitted -- advancing first would simply be undone. But it must not
be an ImGui.Dummy: that submits an item and would replace the control as
g.LastItemData, silently disabling every IsItemDeactivatedAfterEdit save
throttle in the window. SetCursorScreenPos moves the cursor without touching
last-item state.

ToggleSwitch is a capsule with a gliding knob, driven by HoverState so it
animates instead of snapping. Unlike a slider there is no throttle to preserve:
a checkbox commits on the click itself. The caller owns the hit area, so a
settings row can make the whole row clickable, label included.

Named ToggleSwitch, not Toggle: Dalamud's Window base class already has a
Toggle() method, and the plain name collides in every window that uses the
widget.

Geometry lives in WidgetGeometry as usual, with nine new cases pinning the
right-alignment split, the narrow-row fallback (the label yields before the
control does), and that the knob stays inside its capsule at both ends.
ColourUtil gains a two-colour Lerp for the track crossfade.
2026-08-18 11:37:11 +02:00
JonKazama-Hellion 5b51f7dcfd fix(settings): use the existing translations and unfreeze the window title
Review of block A found three things a German user sees straight away.

Two of the three new controls hard-coded English labels although translated
resources already existed for exactly them: Options_PlaySounds_Name and
Options_KeybindMode_Name are in all 25 files. So "Sprache" sat directly above
"Play sounds". The help marker now uses Options_PlaySounds_Description too.

The settings window title was baked in at construction, so it kept whatever
language the plugin started in. Every other string re-reads per draw; this was
the one frozen one. A PreDraw override refreshes it.

Three smaller items from the same review:

The comment on the language order was wrong in both halves. None never reaches
the sort -- it is filtered out and pinned to the front -- so the "list would
jump" hazard it described cannot happen, and the count was 25 endonyms, not 24.
Left as-is it invited someone to swap Where and OrderBy, which would make the
order depend on the culture the game started in.

The rebuild comment claimed thread affinity made it safe. It does not: the
rebuild disposes the very font handle that Plugin.Draw has pushed for the
frame. It works because Dalamud holds the ImFont under a per-frame lock, and
that is now what the comment says. The help marker also warns that switching
rebuilds the atlas and where to clear accumulated glyph ranges.

ApplyLanguage gets an equality guard. Combo only reports real changes, but a
font atlas rebuild is expensive enough that no future caller should be able to
trigger a no-op one.

ChatTab lost its last ChatType reference with the deleted grid, so the using
went too.
2026-08-18 11:33:42 +02:00
JonKazama-Hellion 9a31bbba03 feat(settings): bring back three settings that had no way to reach them
All three drive real behaviour and have translated labels in all 25 language
files. None of them had a control anywhere in the UI -- they were lost in the
v1.6.0 window rewrite and nobody noticed, because the config kept working with
whatever value happened to be stored.

Language picker. This is the one that needed care: a combo alone would have
been wrong twice over. LanguageChanged is an instance method, not static, and
its parameter only matters when the override is None -- it reads the config
itself otherwise. Passing picked.Code() there yields "", so "follow Dalamud"
would have silently meant English. Startup gets this right and is copied.

More importantly the glyph ranges. Their activation used to live in
Settings.Apply, a class that has not existed since v1.6.0; the comment at
Plugin.cs:290 still points at it. Without OR-ing the required range in and
rebuilding the font atlas, switching to Korean renders empty boxes until the
plugin reloads. Four steps, in this order, and the order is forced: the culture
switch reads the config, the atlas rebuild reads the glyph ranges.

Sound toggle. Gates both the per-tab notification sounds and the UI click
sound. It even has a self-test, just no switch.

Keybind mode. Strict versus flexible modifier matching. It has Name() and
Tooltip() per value, so it was demonstrably a control in v1.5.6.

The language order is sorted once into a static: 24 of the 25 endonyms are
fixed literals, so the order does not depend on the current culture, and
recomputing it per frame would make the list jump the moment None's own label
changes.

"Show novice network" moves to Behaviour on the way past. It is a display
filter, and it was the only entry under "Notifications" -- which now holds the
sound toggle it was named for.
2026-08-18 11:20:52 +02:00
JonKazama-Hellion 81b68a1681 fix(settings): stop drawing the same privacy options in two tabs
Three settings were rendered in two places at once, and the third is the one
that matters: the entire 89-entry channel grid existed twice. ChatTab's
"Channel filter" section and DataPrivacyTab's "Privacy filter" section wrote
the same HashSet through near-identical code, differing only in the ImGui id
suffix. Whichever one the user found first, the other silently showed the same
state.

ChatTab's section is gone entirely. DataPrivacyTab is a proper superset of it
-- it additionally carries PrivacyPersistUnknownChannels -- so nothing is lost.
PrintChangelog keeps only its General entry, where it belongs: it is start-up
behaviour, not privacy.

The lock-ordering comment moved before the deletion. DataPrivacyTab's version
was "see ChatTab for the ordering", a cross-reference to the file being
removed; the actual reasoning only existed in ChatTab.
2026-08-18 11:19:32 +02:00
JonKazama-Hellion af242db6f6 chore(release): bump assembly version to 1.10.0
Style foundation cycle closed. Smoke tests green across the sidebar, the tab
strip, the status bar, the theme preview and both message densities.

Download links stay on v1.5.6 deliberately: v1.6.0 through v1.10.0 are local
development states and nobody should update into one. The README badge stays
there too, since it points at the published release.

The roadmap now reflects the actual sequence -- the old "next cycle" entry
still named v1.5.7 ad-block, which has been behind the v2.x UI rebuild since
May.

Verified file by file: csproj, repo.json (both assembly versions, links
untouched), CHANGELOG, ROADMAP. Four TODOs remain in the tree, all pre-existing
and none from this cycle.
2026-08-18 10:13:19 +02:00
JonKazama-Hellion 2686a8f74b feat(i18n): localise the quick-button tooltips
The three quick-button tooltips were hard-coded English while the rest of the
UI goes through HellionStrings. All 25 resource files now carry them, machine
translated on Flo's go-ahead.

Verified: every file still parses as XML, and the base file stays English so a
missing language falls back to what was there before.
2026-08-18 10:09:23 +02:00
JonKazama-Hellion 30c495849d fix(ui): stop the context menu crashing and give the quick buttons their tooltips back
Two things Flo hit on the first run.

The context menu threw on EndPopup. The spacing guard added in 929188e was a
`using var`, which disposes at the end of the method -- after EndPopup. ImGui
asserts when a popup closes with a style var still on its stack. The body moved
into a scoped block so the pop happens inside the popup.

The quick-button tooltips were empty boxes. SetTooltip ran inside the
FontAwesome push, and that atlas has no ASCII glyphs, so the text had nothing to
render with. ImRaii.DefaultFont did not save it. The hovered label is collected
now and drawn after the font is popped. Same trap as the unread badge in C5,
different place.

Those three tooltip strings are still hard-coded English while the rest of the
UI is localised. Pre-existing, and adding resources means touching 24 language
files, so it is noted rather than fixed here.
2026-08-18 09:23:12 +02:00
JonKazama-Hellion ca60c851cd fix(settings): read the preview colours from the same tokens as the chrome
The preview still picked its colours by hand: SurfaceHover for the active row
where the sidebar uses SurfaceActive, and a plain surface for the pills where
the status bar uses SurfaceRaised. Since SurfaceActive was just raised from
0.1 to 0.25, the preview showed a noticeably different active row than the one
next to it.

It resolves through TokenResolver now. Copying the lerp formulas is how it
drifted out of sync to begin with.
2026-08-18 00:20:31 +02:00
JonKazama-Hellion d79188c7b0 fix(statusbar): size pills from the text and give every slot a fit check
Review of block E found the pill height locked to its design value.
WidgetGeometry.Pill discarded the measured text height, so a pill was always
22px times display scale -- and Config.FontSizeV2 does not feed into display
scale. At 18pt the 24px line no longer fit its 22px pill, at 20pt the text left
the reserved strip entirely. Both sizes are in the plugin's own font list.

Height is a floor now, and the text plus vertical padding wins when it is
taller. At the default 12.75pt the floor still applies, so nothing moves.

Only the right-hand version slot checked whether it had room. At 150% scaling
with the window at its 480px minimum -- which stays reachable, because the size
constraint is not scaled -- the counts and tells pills simply ran off the edge.
Every slot checks now.

Pill.Draw returns the size it drew, so the status bar no longer measures each
slot twice. That also removes the risk of the drawn and returned widths
drifting apart if a style override ever reaches only one of the two calls, and
it cuts the lock glyph from three font pushes per frame to one. A font push is
not free: it allocates a lock object and queues a deferred dispose.

Smaller items: the version string is built once instead of per frame, the glyph
cache from IconButton is now in Pill as well, and five metrics constants plus
two usings that lost their consumers in E1 and E2 are gone.
2026-08-18 00:19:39 +02:00
JonKazama-Hellion 929188e5eb fix(ui): respect window opacity and keep badges off the icons
Reviews of blocks C and D found five things a user would see immediately.

Row fills ignored the window's own opacity. Theme surfaces are fully opaque,
and GlobalStyleScope zeroes ChildBg below full opacity so WindowBg alone
carries the coverage -- with the default of 0.85 that made the sidebar a solid
block inside a translucent window. Idle rows now draw no fill at all, and the
active and hover fills are scaled by the current window opacity.

The unread badge landed on the tab icon at the default sidebar width of 44px.
Right-aligning it needs roughly 70px for one digit and 90px for three, and the
old placement also subtracted the popout column even when there was no popout
button. It is only drawn where it clears the icon; below that a plain dot takes
over, which is what the sidebar did before this cycle anyway.

The same collision existed in the top-tab strip, worse: the badge sat in the
trailing padding, which is 10px against a badge at least 14px wide, so it
covered the label on every tab that had one. The badge is part of the tab width
now, and vertically centred rather than top-aligned.

The context menu's spacing guard read the pushed zero back out of GetStyle, so
the max never did anything and X stayed at zero -- which is what HelpMarker's
SameLine uses, so the "(?)" clung to its label. It sets both axes outright now.

Section captions had all their padding above them and one pixel below, so with
zero item spacing the next row started immediately under the text.

Three smaller items: the tab icon was centred against the text font's line
height although FontAwesome is a fixed-width handle that ignores
Config.FontSizeV2; IconButton interpolated a label string per button per frame,
now a PushID over a u8 literal; and the alpha scaling that had grown four
copies now goes through ColourUtil.ApplyAlpha everywhere.
2026-08-18 00:11:53 +02:00
JonKazama-Hellion 891f8ac0ec fix(style): make the active surface actually visible
SurfaceActive was Lerp(Surface, Primary, 0.1f), picked when no production code
drew the token. Against the real sidebar it is barely distinguishable from
SurfaceBase, so the active row was identifiable only by its 2px accent bar --
which is not what "the active tab is unmistakable" was supposed to mean.

0.25 keeps it clearly a surface rather than a coloured block, and stays
distinct from SurfaceHover, which is its own theme slot rather than a
derivation.

Worth a look across all themes during the smoke test: the lerp target is
Primary, so themes with a very light primary will move further than the dark
ones.
2026-08-18 00:05:53 +02:00
JonKazama-Hellion bfc61909cf refactor(ui): scale the remaining layout constants
What blocks A to E did not already touch: the honorific header height and its
two offsets, the message list dummy widths, and the quick-button reserve in the
input bar.

The reserve is the one with visible consequences. At 150% the buttons grow with
the font while a fixed 130px column does not, so they stopped fitting.

The honorific offsets are centred rather than scaled. The 8f there was
(30 - 14) / 2 for the old font, structurally the same case as the sidebar: a
scaled constant keeps its mis-centering, a computed one does not.
2026-08-18 00:05:23 +02:00
JonKazama-Hellion 0d6877ddc6 fix(settings): align the theme preview with the real chrome
The preview had been showing surfaces, an accent bar and an unread marker for
months while the real sidebar drew none of them. Now that the sidebar has
caught up, the preview is the one that is wrong -- in two specific ways.

It put the accent bar on row 0 and the raised surface on row 1, so it showed
two half-active rows instead of one active row. Both now sit on row 0, and the
rows get the separator the real ones have.

The unread marker was a 4x4 square. It is a rounded count badge now.

The status bar preview was a separate 20px reimplementation with three coloured
squares and a hard-coded label. It mirrors the pill layout instead, with the
status colours riding along as slot dots so a theme still shows what it does to
them.
2026-08-18 00:04:00 +02:00
JonKazama-Hellion a7ed8e1146 feat(statusbar): render slots as pills
Five slots drawn as flowing text with a TextDisabled interpunct between them.
They are pills now: channel with its status dot, privacy with its lock glyph,
counts, tells, and version right-aligned.

The composite texts stay composite. FormatCounts produces "5 tabs · 1.2k msg"
and the version slot "v1.10.0 · Hellion" as single strings, and splitting them
would have gained nothing while breaking their pinned format.

Height derives from the pill rather than sitting beside it as a second
constant. MainWindow reserves the body against this property, so the two
drifting apart is the entire failure mode here, and a pill is taller than a
bare text line.

The right-hand slot's fit check actually measures now. The old one compared the
region against a flat 200px and never looked at the left-hand slots at all, so
at the 480px minimum width it kept drawing the version while the left run
needed more room than was left -- the overlap predates this change.

Pill grew optional icon support for the privacy lock. Without it that glyph
would have been silently dropped in the move.

StatusBarCacheTests is re-enabled. It sat in the csproj Compile Remove block,
so the format contract this commit reshapes around had no live net at all. Two
of its cases construct StatusBar with null services, which is safe because
SnapshotForTest touches neither.
2026-08-18 00:02:55 +02:00
JonKazama-Hellion f2dd49e454 refactor(input): move the channel pill onto the pill widget
The pill was already hand-drawn here -- filled rect, rounding 6, a frozen 3px
text offset and an InvisibleButton over the top. That is the Pill widget, built
before the widget existed, so it becomes the widget's first consumer. Its text
now centres against the measured line height instead of the frozen offset.

InputBar.Height moves to Metrics as well. MainWindow and ChannelPopoutWindow
both reserve their body height against it, so both follow without changes. It
had to happen in the same commit: the pill inside the bar now scales, and a bar
that did not would have clipped it at 150%.
2026-08-18 00:00:15 +02:00
JonKazama-Hellion 918cdc8111 fix(style): stop the hover registry from churning on idle rows
Review of block B found Query allocating an entry for every element it was
asked about, hovered or not. The cycle: Query creates the entry, the next
BeginFrame steps it to zero, evicts it, and the next Query creates it again.
With fifteen tabs that is fifteen allocations plus fifteen dictionary inserts
and removes per frame, permanently, with the mouse nowhere near the window.

That is exactly the property master spec 7.5 asks for and the one this block
claimed to improve, so it ate the two string allocations 31fa410 had just
saved. An unhovered element with no entry now returns zero without creating
one.

The footprint self-test only ever queried with hovered: true, which is why it
could not see this. It now runs an idle phase as well.

Four smaller items from the same review:

Advance skipped clearing the hover flags when deltaTime was zero, so such a
frame carried the previous frame's state forward.

Metrics reads GlobalScaleSafe now. The unsafe variant throws while the
interface manager is still coming up, and block F pulls Metrics into more call
sites.

Badge.CalcSize returned a full-size box for a count of zero while Draw drew
nothing, so a caller that reserves and then draws left a badge-shaped hole on
every tab without unread messages -- the normal case.

IconButton caches its glyph strings; ToIconString allocates on every call and
keeps no cache of its own. And the widget gallery clamps its own row width,
since asserting on a zero-width button in the window that demonstrates the
clamp would be a poor look.
2026-08-17 23:59:17 +02:00
JonKazama-Hellion 5696eac55a test(selftest): pin the top-tab underline invariant
TopTabBar had no observability at all -- no counter, no self-test reaching it.
It now exposes LastRenderedUnderlineCount and MainWindow hands the component
out the same way it already does for the sidebar.

Three cases: one of two tabs active draws exactly one underline, a null active
tab draws zero, and an active tab that is not in the list also draws zero. The
last one matters because the strip skips popped-out tabs, so the active tab
legitimately need not be among the drawn ones.
2026-08-17 23:57:11 +02:00
JonKazama-Hellion e4659bc597 feat(toptabs): draw tabs with an active underline instead of selectables
The strip was ImGui.Selectable sized to the bare text width, with a red dot
hanging off the item rect. Six things had to be rebuilt by hand, and the first
is the one that mattered most.

Selectable did have an active fill (ImGuiCol.Header, fed from the theme), so
this replaces a fill rather than adding a marker to something bare. The fill
stays and the accent underline comes on top -- swapping one for the other would
have made the active tab harder to spot.

Tabs now have their own height derived from the measured line height plus
padding, so the strip no longer collapses onto the text. Hover runs through
HoverState like the sidebar, and the label has three states: active and hovered
in Text, idle in TextMuted.

The unread marker is a count badge in AccentEmber, and its position had to be
recomputed: Selectable inflated its bounding box by half the item spacing on
every side, so reusing the old GetItemRectMin/Max maths against an
InvisibleButton would have made the marker jump.

Click semantics follow InvisibleButton's return value, which fires on release
like Selectable did. IsItemClicked would have fired on press, a silent
behaviour change.

The trailing Separator becomes a LineDivider, so both layout modes draw the
same rule.
2026-08-17 23:56:29 +02:00
JonKazama-Hellion 5caa266287 test(selftest): pin the sidebar active-surface invariant
Drives the real Sidebar.Draw and reads the render counter, so a regression in
the draw path fails instead of a parallel calculation passing.

Three cases: one of two tabs active draws exactly one surface, a null active
tab draws zero, and icon-only mode still marks the active row.

Zero is a legitimate state, not a failure -- PickMainActiveTab returns null
when every tab is popped out, so the invariant is "at most one", not "exactly
one".
2026-08-17 23:54:13 +02:00
JonKazama-Hellion 9936638bb3 feat(sidebar): show unread counts as accent badges
The unread marker was a 4px dot in StatusDanger. Red reads as an error, and an
unread message is not one. It is now a count badge in AccentEmber, which is
what the theme preview in settings has always shown.

It has to be drawn outside the FontAwesome scope: that atlas carries no ASCII
digits, so the number would have come out blank inside it. The icon width is
still measured inside the scope and handed out.

Placement follows the mode. Expanded puts the badge right-aligned ahead of the
popout slot, where a three-digit count still fits; icon-only keeps it over the
icon like the old dot.

The frozen vertical offsets go at the same time. The 8f was (32 - 16) / 2 for a
16px font and stayed wrong at every other Config.FontSizeV2, which display
scaling does not feed into. Both the icon and the label now centre against the
measured line height, and the label starts after the measured icon width
instead of a hard 32f.
2026-08-17 23:53:30 +02:00
JonKazama-Hellion 293d56bdbe refactor(sidebar): move popout and greeted toggles onto the icon-button widget
Both were hand-rolled: an InvisibleButton, then a glyph pushed through the
FontAwesome handle onto the draw list, with a frozen 8px vertical offset and a
4px inset. IconButton does the hit area, the hover fill and the centred glyph,
and centres against the measured line height instead of the frozen offset.

Their order is unchanged. Both still sit after TabContextMenu.Draw, which is
the ordering the popup trigger depends on, and the caller keeps what actually
differs between them: cursor placement (SameLine vs. absolute), the visibility
rule, the glyph choice and the greeted glyph counter that
SidebarGreetedGlyphStep pins.

The popout glyph now follows the row surface rather than rowHovered ||
popHovered. That pair needed the button's own hover state one line before it
existed, and the button sits inside the row anyway, so the row's hover covers
both cases.
2026-08-17 23:52:07 +02:00
JonKazama-Hellion eacefb355d feat(sidebar): draw row surfaces, accent bar and separators
The sidebar drew a hover sweep, an icon and a label per row and nothing else.
No hover fill, no active fill, no accent bar, no separator -- the active tab
was visually indistinguishable from the rest. The theme preview in settings has
been showing all of it for months without the real sidebar delivering any.

Rows now go through the Row widget: base surface, active surface, hover
interpolated between them, a 2px accent bar on the active row and a bottom
separator.

Hover is detected with IsMouseHoveringRect, not IsItemHovered. The row button
is up to two hit widths narrower than the row itself (popout slot, greeted
slot), so a full-width surface driven by the item would flicker at the edges.
AllowWhenBlockedByActiveItem is required on the window check, otherwise the
fill disappears the moment the button is pressed, because InvisibleButton owns
the active id by then.

ItemSpacing is pushed to zero around the row loop so surfaces sit flush instead
of leaving a stripe of window background between them. Style vars are a global
stack and the context menu inherits them, so TabContextMenu now restores a
normal spacing inside its popup -- without that its entries would touch.

The row surface is drawn strictly with draw-list calls between the row button
and TabContextMenu.Draw, which is the ordering the popup trigger depends on.
2026-08-17 23:50:52 +02:00
JonKazama-Hellion 464bd52887 refactor(sidebar): move section headers onto the divider widget
Separator plus TextDisabled took all of their vertical breathing room from
ItemSpacing. The next task pushes ItemSpacing to zero so rows sit flush, which
would have collapsed the header onto its neighbours.

LineDivider carries its own padding and submits its own layout item, so it no
longer depends on the surrounding spacing at all. The compact branch keeps
suppressing only the caption, and LastDrawnSectionHeaderCount still increments
exactly where the caption is drawn -- SidebarSectionHeaderStep pins it at 2
with compact off and 0 with compact on.
2026-08-17 23:49:15 +02:00
JonKazama-Hellion 1e8a60ac80 fix(sidebar): scale the drawn width without moving the stored value
The sidebar constants were raw pixels. At 150% display scaling the text grows,
the column does not, and the row contents stop fitting.

GetWidth and IsExpanded stay unscaled on purpose. The stored width and the
switch threshold are user settings in design pixels, and
SidebarModeAutoSwitchStep compares GetWidth's return value against the raw
bounds with exact equality -- scaling there would fail the step at anything
other than 100%. Scaling happens once, at the single draw call site.

RowHeight and the two hit widths now come from Metrics. The row internals read
GetContentRegionAvail, so they follow automatically and the hit-area split
thresholds stay proportional.

The not-ready branch is scaled too: a scale change triggers a font rebuild, so
that branch really is hit while GlobalScale is moving, and an unscaled width
there makes the sidebar jump.

The width slider referenced the bounds as literals. It now uses the constants,
so it cannot drift away from the clamp.

Known remainder, deliberate: SidebarAutoSwitchThresholdPx is compared against
real screen pixels while the columns now scale, so the switch point drifts at
high scaling. Scaling it would fail the same SelfTest. Noted for v1.11.0.
2026-08-17 23:48:36 +02:00
JonKazama-Hellion 10b345821f fix(messages): apply density switches immediately, freeze the cache while waiting
Review of block A found two regressions the settle gate introduced.

Density and the two name modes are switches, not sliders. They land on a new
value in one frame and stay there, but the gate made them wait 200ms like a
drag. Meanwhile the row painter switched instantly, so for about twelve frames
the planner ran compact rows against card heights: wrong slice, wrong offset,
and the visible rows got overwritten with the new density while everything
else kept the old one. Before the gate this was correct, so it was a
regression, not a pre-existing bug. The fingerprint now separates a discrete
axis that applies at once from the continuous one that waits.

Second, the cache did not actually stand still during the wait. Measurements
were written back unconditionally, so a resize drag mixed heights from many
different widths and the lead dummy drifted for the whole drag. The gate now
exposes IsPending and the planned path skips the write while it is set. The
linear path still measures, because without a filled cache there is nothing to
plan against.

Three smaller items from the same review:

An oscillation slower than the settle window used to bypass the wait entirely,
because the pending clock was never reset on the way back to the applied value.

A value that changes on every frame could hold the gate shut forever while the
applied fingerprint stayed wrong. MaxWaitMs is the deadline, checked before the
still-moving branch so a permanently moving value actually reaches it.

The row painter was passed as `compact ? DrawCompactRow : DrawCardRow`. Both
are instance methods, so the method group captures `this` and Roslyn does not
cache it: 64 bytes per frame per window. Bound once in the constructor now. The
per-frame height array is also reused rather than reallocated, which at the
default MaxLinesToRender was 10 KB per frame on what A2 had just made the
default path.
2026-08-17 23:46:43 +02:00
JonKazama-Hellion 460217459c chore(style): add a widget gallery for visual verification
Every widget in its states, reachable via /hellion widgets. The point is to
check them one at a time before they land in real components: the v2.x style
engine grew three primitives that were never wired to a call site
(DrawGlowBorder, DrawSlipPolygon, DrawHonorificHeader), and this is the cheap
way to notice that before a cycle closes.

DEBUG-only, like SeStringDebugger. It is a verification aid, not a feature, so
it never reaches a release build -- verified against a Release compile.

The header line also shows the live GlobalScale and the hover registry size,
which makes both the scaling work and the eviction contract observable while
dragging the Dalamud scale slider.
2026-08-17 23:42:05 +02:00
JonKazama-Hellion d6ad81e8ca feat(style): add the drawn-widget set
Five widgets, each with a real consumer inside this cycle: Row for sidebar
rows, Badge for unread counts, IconButton for the popout and greeted toggles,
LineDivider for section headers, Pill for the channel pill and the status bar.

Row carries no Tab on purpose. v1.11.0 moves the sidebar from tab rows to
channel rows, and if the chrome sits in a Tab-free widget that cycle only has
to change the caller.

IconButton is deliberately small. Its two sidebar call sites differ in
placement, visibility rule, glyph choice, and one of them bumps a SelfTest
counter. Taking all of that as parameters would produce a widget that is five
switches and no behaviour, so the caller keeps them.

LineDivider brings its own vertical padding and submits its own layout item.
The sidebar is about to push ItemSpacing to zero so rows sit flush, and a
divider relying on spacing would collapse onto its neighbours there.

Colours go through WidgetPalette. TokenResolver returns RGBA and ImDrawList
expects ABGR; with five widgets that is the same swap-red-and-blue trap five
times over.

Sizes go through WidgetGeometry, which is pure so the build suite can pin it,
and clamps every result to a positive extent -- ImGui asserts on a zero-sized
InvisibleButton and takes the window with it.
2026-08-17 23:40:15 +02:00
JonKazama-Hellion 31fa410875 fix(style): drive the hover sheen from held state, drop the leaking start map
The sheen kept its own Dictionary<string, DateTime> of start timestamps and
only cleared an entry in the un-hover branch. A row that disappeared while the
pointer was on it left its entry behind until the plugin reloaded, which is
exactly what happens to temp tabs under the LRU limit.

It now takes the held intensity from HoverState and draws on the rising edge
only. The alpha falls off as the value climbs, so the sweep has faded out by
the time the surface underneath is fully in. On the way out it simply does not
run, which is what stops it from travelling backwards -- the old
SheenStarts.Remove prevented that by resetting, and dropping the map without
this guard would have reintroduced it.

The sidebar call site built "sidebar.tab.{guid}" per row per frame, two
allocations each. Master spec 5.3 asks for constant keys, and 7.5 for a stable
allocation count. It now uses ImGui.GetID("row"u8), which is allocation-free
and seeded from the window's ID stack, so the same literal stays distinct per
window and per PushID'd tab.

HoverSheenAllocStep pinned the old dictionary contract and is replaced by
HoverStateFootprintStep, which pins the same property against the registry:
repeated queries add no entries, and an element that stops being queried leaves
the map instead of leaking.
2026-08-17 23:37:24 +02:00
JonKazama-Hellion bf0c3e2bd7 feat(style): hold hover state across frames instead of one-shot sweeps
DrawHoverSheen measured its own elapsed time against DateTime.UtcNow and gave
up after 0.65s, so a row stopped reacting while the pointer was still on it.
There was no held value to interpolate colours against.

HoverState keeps one 0..1 intensity per element, rising at 14/s and falling at
8/s. Slower out than in is what makes the fade read as deliberate.

Query and advance are separate on purpose. Several SelfTest steps call
Sidebar.Draw against the live tab list, so the same element gets submitted up
to three times in one frame, twice from a window the mouse is not over. If the
query advanced the value, the last caller would win and the fade would run
backwards. Query only ORs the hover flag; BeginFrame does all the moving and
the eviction.

BeginFrame sits above the HideInLoadingScreens and New Game+ early returns, so
a hidden main window still lets pop-out hovers fade out instead of freezing
mid-blend.

FrameLerp gains Ramp: Smooth approaches asymptotically and never arrives, so a
value driven by it would never reach zero and never become evictable.

ReduceMotion short-circuits before the map is touched, returning a hard 0 or 1.
An infinite rate would produce NaN and poison the entry for the session.
2026-08-17 23:35:54 +02:00
JonKazama-Hellion 28b97b3f70 feat(style): add a scale-aware metrics layer
Layout constants lived as bare floats in the components: row heights, hit
widths, insets, reserve widths. None of them multiplied by GlobalScale, so at
125% or 150% display scaling the text grows and the boxes do not. The quick
buttons stop fitting into their 130px column.

Metrics holds the design values and exposes scaled properties. The Raw
constants stay reachable for the few places that must store or compare an
unscaled value: Sidebar.GetWidth (a SelfTest pins it against raw numbers) and
the width slider bounds.

Scale is pinned once per frame against ImGui.GetFrameCount(). GlobalScale is a
live value the Dalamud slider moves on every dragged frame, so reading it per
access can shift a CalcSize away from its matching Draw inside one frame, and
costs three native calls each time.

Not in ThemeLayout: that record is serialised into theme JSON, and layout
customisation is out of scope per the master spec.

MetricsMath is the pure half so the build suite can pin the arithmetic. Text
heights are measured rather than scaled -- the font is built from
Config.FontSizeV2, which GlobalScale does not feed into.
2026-08-17 23:34:08 +02:00
JonKazama-Hellion 0ef33934a2 refactor(messages): plan compact rows like cards instead of assuming a fixed height
Compact mode ran an ImGuiListClipper with CompactRowHeight = 18f. Two things
were wrong with that.

The number: at the default 12.75pt the font is 17px, and ChunkRenderer pushes
ItemSpacing to zero for the whole chunk loop, so a single-line compact row
advances the cursor by 17, not 18. The clipper seeded the cursor one pixel too
low per row, which accumulates into a visible drift against the scrollbar.

The assumption: compact rows are not constant height at all. DrawCompactRow
renders content with wrap: true, and WrapEncodedLine submits one text item per
wrapped line. At 620px and 17px type that kicks in around 70 characters, so
most chat lines are multi-line.

Both densities now share DrawRows/DrawLinearAndMeasure over the existing
CardClipPlanner, with the row painter passed in. The fixed height and the
clipper are gone.

Also corrects the CompensatedDummy comment: the cached heights carry no
trailing ItemSpacing (every row ends inside DrawChunks, where spacing is
zero, and ImGui writes the advance at submission). The compensation is
correct because it cancels the spacing the dummy itself appends.

CardClipPlanStep drives the invalidation hook directly, which now sits behind
the settle gate, so it walks a synthetic clock past the window.
2026-08-17 23:32:04 +02:00
JonKazama-Hellion 39a8e95581 fix(messages): invalidate the height cache when UI scale changes
The layout fingerprint tracked font size, density, both name modes and
content width, but not ImGuiHelpers.GlobalScale. Scale feeds
CalcWordWrapPositionA, so changing it rewraps every row while the cached
heights stay put and the clipper dummies drift against the scrollbar.

Two further problems came out of the same code:

The fingerprint lived in a single field on MessageList while the cache it
guards is per tab. Resizing in tab A marked the new value applied, so tab B
kept measuring against the old width. It is now a gate per tab identifier.

Acting on every fingerprint change is too eager. A window resize or a drag on
the Dalamud UI-scale slider moves the value on every frame, and each change
drops the cache and forces the linear measure path over the whole tab (up to
Config.MaxLinesToRender rows). The gate now waits for the value to settle for
200ms, which turns a drag into one rebuild instead of one per frame.

The settle logic sits in Util/LayoutFingerprint.cs as a plain value type so
the build suite can pin it without standing up an ImGui frame.
2026-08-17 21:17:38 +02:00
JonKazama-Hellion 3583dfc032 perf(tells): build the tab outside the lock, guard pin transitions
HandleTell was one atomic block, and PreloadHistory sat inside it -- so every
new tell partner held TabsListLock across a store query that sorted the whole
receiver history before returning a row. That is the lock the draw thread and
the message worker both wait on.

Now three steps: look for an existing tab under the lock, build the new one
(including history) without it, then commit under the lock again. Splitting it
opens a window where the world can change, so the second block re-checks:

- FindTempTab again, in case something else created the tab meanwhile. The
  message goes to that one instead. Not in the first block's early return --
  HandleTell runs after the delivery loop, so an existing tab already has it and
  adding again would duplicate the line.
- A generation counter, bumped by OnLogout under the same lock. A logout in
  between wipes the unpinned pool, and without this the freshly built tab would
  outlive it and show up for a character we already left. Not via
  CurrentContentId: its getter falls back to a cached value, so the comparison
  can silently pass.
- The pool cap moves into CommitTempTab and stays there exactly once. Evaluating
  it twice would evict a tab on every spawn.

Pin, unpin and promote take the lock around the flag change now -- they decide
pool membership and whether a save strips the tab. SaveConfig stays outside, so
no fsync lands on the click path.

DropOldestTempTab removes by reference: the index came from an earlier Select in
the same block and would point at the wrong tab if anything shifted the list.
2026-08-17 18:38:08 +02:00
JonKazama-Hellion c34024a18b chore(release): bump assembly version to 1.9.0
The whole v1.9.0 cycle ran without a version bump, so the plugin still reported
1.8.8 in-game. For a tester beta that is untenable: bug reports would name a
version that does not identify the code they ran.

Download links stay pinned to v1.5.6 on purpose. There is no v1.9.0 release, and
pointing at an artifact that does not exist is worse than the visible mismatch
between assembly version and download target. Public stays on v1.5.6 until
v2.0.0.
2026-08-17 07:27:59 +02:00
JonKazama-Hellion eaed0b13e0 fix(config): guard the shared config maps, restore lost fields in Tab.Clone
TabsListLock never covered ChatColours, PrivacyPersistChannels or
RetentionPerChannelDays, yet the settings UI mutates them from the draw thread
while the retention thread can be serializing the same config. Adding a new key
to a dictionary or a new element to a set invalidates a running enumeration, so
this could throw from inside JsonConvert.SerializeObject.

Not a corner case: the colour picker lists 66 channels but only 25 ship with a
default, so the first edit of any of the remaining ones inserts a new key -- and
the reset button removes a key, which makes the next edit a fresh insert again.

The readers matter as much as the writers. IsAllowedForStorage runs per message
on the worker thread and asks PrivacyPersistChannels whether a channel may be
stored; a Contains racing an Add that resizes buckets can answer wrong, and that
answer decides whether a message is written to disk. The retention sweep
enumerates RetentionPerChannelDays on the framework thread while the wizard can
clear it -- Clear does not throw there, it just cuts the enumeration short, so
the sweep would run on half a policy.

New ConfigMapsLock covers all of it. It sits inside TabsListLock (that edge is
real, AutoTellTabsService calls SaveConfig while holding the tabs lock), never
the other way round -- so every call site closes the lock before saving.

Tab.Clone silently dropped Icon and ChatCodes, both serialized. A reflection
test now walks the serialized fields so a future one cannot slip past.

Also: CurrentTab read Count and [0] as two separate accesses.
2026-08-17 07:27:48 +02:00
JonKazama-Hellion 2b4243599e fix(ui): render each frame from one tab-list snapshot, key widgets by identity
Sidebar, top tabs and status bar each read Config.Tabs on their own, unlocked,
while the worker added or evicted tabs. That gave three independent views of a
moving list: an index built in one place could resolve to a different tab a few
lines later, which showed up either as an out-of-range crash on the draw thread
or -- worse, because it is silent -- as a click landing in someone else's tell.

MainWindow now takes one snapshot under the lock and passes it through the whole
frame. Deliberately a shallow copy: tab identity is compared by reference all
over the draw path, so cloning would break every ReferenceEquals and Contains.
ChangeTabDelta and ResetActiveTabIfRemoved run on the framework thread and keep
their own locked reads instead; ThemeQuickPicker locks its own copy, since
reaching it would mean threading a parameter through InputBar, which popouts
share and which has no tab list.

Widget IDs move from list position to tab.Identifier. ImGui carries popup and
widget state across frames under that ID, so a position-based one re-binds an
open context menu to a different tab as soon as the list shifts -- a snapshot
cannot fix that, it spans frames. This also resolves top tabs visually merging
into each other when the list changed.

The sidebar section headers counted over the live list while the rows came from
BuildRenderOrder, which skips popped-out tabs. Both sides take the same
predicate now, so the count matches what is drawn.
2026-08-17 07:27:34 +02:00
JonKazama-Hellion 24dff3cc2e fix(messages): snapshot the tab list before delivering a message
ProcessMessage walked Config.Tabs live on the worker thread while SaveConfig's
strip and the auto-tell spawn mutated the same list under TabsListLock. The
resulting "collection was modified" was caught by the pending-message handler
and only logged -- so the message was dropped entirely: no tab entry, no sound,
and MessageProcessed never fired, which also meant no tell tab and no routing.
Silent message loss, exactly under the load where it hurts.

The loop now runs over a snapshot taken under the lock. AddMessage stays
outside it, so the lock order (list outer, MessageList inner) is unchanged.

SelectNotificationSound reports which tab it picked, so playback can skip a tab
that disappeared between snapshot and sound -- otherwise the snapshot would let
an evicted tab still make noise.

While here: the current tab was read twice despite the comment claiming it was
snapshotted once.
2026-08-17 07:27:21 +02:00
JonKazama-Hellion 89c66e0d3d perf(store): index (Receiver, Date) so tell history streams sorted
GetTellHistoryWithSender filters on Receiver and orders by Date DESC. Without a
matching index SQLite sorted the whole receiver history into a temp b-tree
before yielding row one -- measured 10 ms to first row against 9621 tells, all
of it under TabsListLock, which defeats the early break in the caller.

(Receiver, ChatType, Date) does not help: the ChatType IN filter sits between
the equality prefix and the sort column, so the temp b-tree stays. Verified on
a real database: plan now reads SEARCH ... USING INDEX idx_messages_receiver_date
with no sort step.

No SQL LIMIT -- an earlier 500-row cap was removed in v1.4.10 because it cut
less-frequent partners off the back of the window, and 83% of partners have
fewer than 21 tells in total.

The migration dispatcher is cumulative, so Migrate5 is appended to every
existing case, not just the new one.
2026-08-17 06:50:04 +02:00
JonKazama-Hellion d2da51a4f7 perf(settings): write config on release instead of every slider frame
ImGui sliders report a change in every frame the value moves, so dragging one
rewrote the full 31 KB config to disk per frame -- serialize, fsync and rename,
synchronously on the draw thread. Measured on Linux/Wine that showed up as a
114 ms frame while the plugin itself only drew for 2.9 ms; the rest was waiting
on the write.

The five shared slider helpers now defer SaveConfig to IsItemDeactivatedAfterEdit,
matching what ChatColourPicker already did for the colour wheel.

Renaming a tab needed its own path: the input lives inside a popup, and ImGui
never re-submits it when the popup is dismissed by clicking outside, so
IsItemDeactivatedAfterEdit would not fire and the new name would be lost. A
pending-rename marker scoped to the owning tab flushes it when the popup is
gone -- scoped, because every other tab's Draw reaches that branch too.

DeferredSaveFrames is removed: the debounce was fully wired but never armed,
and this approach makes it redundant.
2026-08-17 06:49:53 +02:00
JonKazama-Hellion 99dca8cb31 fix(selftests): take TabsListLock around Config.Tabs mutations
The three sidebar/coupling steps add, insert and remove tabs straight from
the framework thread while the message worker mutates the same list under
TabsListLock. CurrentTabCouplingStep's Insert(0, ...) is the worst of them:
it shifts every index, so DropOldestTempTab can remove the wrong tab between
its index lookup and RemoveAt.

Locks sit around the individual mutations, never around a Draw call, so no
step holds the lock across rendering.
2026-08-17 06:49:42 +02:00
JonKazama-Hellion ce5973aea9 Merge remote-tracking branch 'origin/main' into feature/v1.9.0 2026-08-16 21:08:20 +02:00
JonKazama-Hellion de9d11ba4a docs: v1.9.0 comment-pass (Z-3) — fix false TEST-MIRROR paths, comment accuracy + density 2026-06-16 20:59:09 +02:00
JonKazama-Hellion 618e029ff4 perf(tabs): share Plugin.TabsListLock across AutoTellTabsService + MessageManager refilter + SaveConfig (B3) 2026-06-16 20:39:56 +02:00
JonKazama-Hellion 93f4fbba72 perf(card): variable-height clipper + layout-fingerprint cache invalidation + clip-plan self-test 2026-06-16 20:09:45 +02:00
JonKazama-Hellion 1d69d0cc30 B2: add Dalamud-free CardClipPlanner variable-height clip-plan helper 2026-06-16 19:58:02 +02:00
JonKazama-Hellion b048a51534 B1: dedupe CJK/symbols merge via AddCjkAndSymbols, trim fallback range; FontsReady + report self-test 2026-06-16 19:53:39 +02:00
JonKazama-Hellion 7d2fd1ab65 selftest: add on-disk SelfTestReport log; report PASS/FAIL details from steps 2026-06-16 19:53:39 +02:00
JonKazama-Hellion 2c5c40524d B1: add Dalamud-free CjkFallbackRange helper + coverage tests 2026-06-16 19:25:34 +02:00
JonKazama-Hellion 0508a05bab test(selftest): add GlobalStyleScope GC-reserve alloc probe 2026-06-16 19:23:15 +02:00
JonKazama-Hellion 32013babaf perf(style): make GlobalStyleScope.StackHandle GC-free via counter scope 2026-06-16 19:23:15 +02:00
JonKazama-Hellion 430c8f235a perf(baseline): 1000-frame steady-state capture with quad-proxy draw calls + JSON sink 2026-06-16 19:13:45 +02:00
JonKazama-Hellion 68c4e28495 perf(baseline): time full Draw() handler into LastDrawMs field 2026-06-16 19:13:45 +02:00
JonKazama-Hellion 458df3b4bd Restore permanent-REPLY game-side tell pre-targeting (1.5.6 parity) 2026-06-16 19:03:34 +02:00
JonKazama-Hellion 6733c7f92e Restore pop-out exclusivity: hide popped tabs from main window + keybind/unread parity 2026-06-16 18:30:23 +02:00
JonKazama-Hellion fa20b53455 C2/C3: restore rotation keybinds (REPLY/LS-cycle) + focus contract routing on the focused chat surface 2026-06-16 14:09:28 +02:00
JonKazama-Hellion ea3f00f107 GP-04: reset Tab.PopOut on load via shared helper (clears stale pinned flags) 2026-06-16 13:26:17 +02:00
JonKazama-Hellion 5bdf4217d6 D1-3 (XC-8): route PayloadHandler Send-Tell through shared BuildTellCommand (IsPublic at call-site) 2026-06-16 13:10:22 +02:00
JonKazama-Hellion 97e58934d6 D1 (XC-8): extract shared PrefillTellInput/BuildTellCommand for the two tell-prefill detours 2026-06-16 13:03:26 +02:00
JonKazama-Hellion f05d7104af A3: gate pop-out affordance on expanded sidebar (icon-only overlap fix) 2026-06-16 12:33:26 +02:00
JonKazama-Hellion e98cefa0b2 A2: name Sheen tuning knobs + SmoothStep crossfade easing 2026-06-16 12:29:57 +02:00
JonKazama-Hellion c1765ce8ca A1: harden HoverSheenAllocStep with real dictionary-footprint assertion 2026-06-16 12:22:29 +02:00
JonKazama-Hellion 7ead120213 A1: scharfschalten DrawHoverSheen accent-tint (Variante A) 2026-06-16 12:17:39 +02:00
JonKazama-Hellion f5ba1c0246 A1: add ColourUtil.LerpTowardWhite accent-tint helper 2026-06-16 12:02:53 +02:00
JonKazama-Hellion b372ab84c3 Merge branch 'feature/v1.8.0' into main 2026-06-16 09:18:12 +02:00
JonKazama-Hellion c27785f9f6 Merge branch 'feature/v1.8.0-closeout' into feature/v1.8.0 2026-06-16 09:07:57 +02:00
JonKazama-Hellion 6f71b09331 fix(closeout): address closure-review findings
- gate keybind pill-sync on IsChannelOrExistingLinkshell so an empty
  linkshell slot no longer desyncs the pill from the real send channel
- close manually-popped pop-out windows on logout via an IsOpen filter
  instead of the PopOut flag (which manual pops never set)
- read the router's tell-tab lookup through a lock-wrapped accessor so the
  framework thread cannot enumerate Config.Tabs mid worker-thread mutation
- add a "switch on every tell" toggle (default on) and make the auto-open
  mode pick the matching layout, so Sidebar vs Top-tab are distinct
- comment corrections (stale/contradictory text, TEST-MIRROR path depth)
2026-06-16 09:04:18 +02:00
JonKazama-Hellion 49f5119b17 feat(popout): arm the auto-tell pop-out settings and add the pool self-test 2026-06-16 01:29:59 +02:00
JonKazama-Hellion 6578c10b13 feat(settings): restore the tab-cycle keybind binder UI 2026-06-16 01:21:05 +02:00
JonKazama-Hellion 3878869904 feat(keybind): cycle tabs and switch channel with pill sync 2026-06-16 01:21:05 +02:00
JonKazama-Hellion 88491902eb feat(chat): prefill the input bar for context-menu and direct-chat tells 2026-06-16 01:04:34 +02:00
JonKazama-Hellion 47a49de8c0 feat(tell-router): auto-open incoming tells per TellAutoOpenMode 2026-06-16 00:50:45 +02:00
JonKazama-Hellion 7b6871fea4 feat(autotell): wire temp-tab pop-outs to the channel-popout pool 2026-06-16 00:39:01 +02:00
JonKazama-Hellion 8e2d333130 fix(toptab): size each tab selectable to its label width 2026-06-16 00:24:18 +02:00
JonKazama-Hellion 4db2ad99b9 Merge branch 'feature/v1.8.8' into feature/v1.8.0
v1.8.8 Block 4b -> full theme/window restoration (last of the 1.8.x restore
series). B4b export-button + schema-v2 default-fill, then P1-P8: custom-theme
selection, typography font-size apply, font-selection UI, theme-card mockup,
chat-colour editor, header theme/tab quick-picker, window/display toggles
(title bar, hide button, 24h clock), and hide-window + Enter-to-restore.
Local-only; manifest 1.8.8; all self-tests green.
2026-06-15 21:08:30 +02:00
JonKazama-Hellion a73f4d0d0c feat(window): restore hide-chat-window + Enter-to-restore (1.5.6)
The eye/hide button now hides the HellionChat window (runtime-only, via a new
DrawConditions gate) instead of toggling native-chat suppression, matching 1.5.6.
The chat-activation keybind (Enter / "/"), whose dispatch was a dead stub in the
KeybindManager since the v1.6.0 rewrite, is re-wired to MainWindow.ActivateChat:
it un-hides, opens if closed, brings the window to front and focuses the input --
so the chat reacts to Enter again from any state. /hellion is a reliable one-press
recovery (Toggle now clears the hide), and the window always shows on login
(start state no longer read from the persisted flag). Adds HideRestoreSelfTestStep.
2026-06-15 20:49:14 +02:00
JonKazama-Hellion b397591ba4 feat(window): restore the title-bar, hide-button, and 24-hour-clock toggles
Re-wires four 1.5.6 settings that survived the v1.6.0 rewrite as dormant config
fields but lost their UI + consumers:
- ShowTitleBar / ShowPopOutTitleBar: gate ImGuiWindowFlags.NoTitleBar on the main
  window (ResolveFlags) and pop-out windows (new PreDraw). Inverted logic matches
  1.5.6 (flag set only when the toggle is off).
- ShowHideButton: gate the input-bar hide button on the toggle.
- Use24HourClock: add the toggle (MessageList already consumes the field).
New 'Window style' section in WindowTab; Use24HourClock in ChatTab display modes.
MainWindowFlagsStep extended with the NoTitleBar fresh-base contract.
2026-06-15 20:19:59 +02:00
JonKazama-Hellion 6813b80d58 feat(themes): restore the header theme/tab quick-picker
Brings back the 1.5.4 quick-picker lost in the v1.6.0 rewrite: a palette button
in the input-bar button row (left of the cog) opens a popup that switches the
theme (built-in + custom, active row checked) and jumps between chat tabs without
opening settings. Theme switch mirrors the settings ThemePicker; the tab jump
routes through a new MainWindow.ActivateTab that replays the click path
(previous -> set -> OnTabActivated) so tell/unread handling is unchanged. Main
window only -- pop-out InputBars get a null picker. Adds QuickPickerSelfTestStep.
2026-06-15 19:53:31 +02:00
JonKazama-Hellion 0352e6c199 feat(themes): restore the chat-channel colour editor in the appearance tab
Brings back the 1.5.6 per-channel colour editor lost in the v1.6.0 rewrite:
presets (incl. brand styling), per-ChatType reset/import-from-game/colour-edit
over Config.ChatColours, the colour-selected-input-channel toggle, and the
theme adopt-banner. The colour-edit drag recolours live but defers SaveConfig
to release (IsItemDeactivatedAfterEdit) to avoid a per-frame full-config write.
2026-06-15 19:12:09 +02:00
JonKazama-Hellion 8cbf9c554f feat(themes): restore the per-card theme preview mockup in the picker 2026-06-15 18:49:45 +02:00
JonKazama-Hellion 18834cddae feat(fonts): restore the font-selection UI in the appearance tab 2026-06-15 18:41:03 +02:00
JonKazama-Hellion fe0414dd2b feat(themes): apply theme typography font-size overrides on every activation path 2026-06-15 18:19:08 +02:00
JonKazama-Hellion ec92cf2c04 feat(themes): list custom themes in the theme picker 2026-06-15 18:03:39 +02:00
JonKazama-Hellion 512533ed3a feat(themes): default-fill missing colour/layout slots on theme load 2026-06-15 16:55:50 +02:00
JonKazama-Hellion a2d8a9c223 feat(themes): add an export button for the active theme 2026-06-15 16:55:50 +02:00
JonKazama-Hellion dcf089b886 chore(release): bump manifest to 1.8.8 for theme export, default-fill, font-apply 2026-06-15 16:18:05 +02:00
JonKazama-Hellion c4562dd6a0 style(selftests): wrap HonorificTitleData ctor to satisfy csharpier
Inherited csharpier drift from the 1.8.7 merge (72099c8); format-only, no behaviour change.
2026-06-15 16:18:05 +02:00
JonKazama-Hellion 74f7bb3fc1 Merge branch 'feature/v1.8.7' into feature/v1.8.0 2026-06-15 14:50:02 +02:00
JonKazama-Hellion 8afcb87624 test(about): assert integrations status through the real render 2026-06-15 14:44:26 +02:00
JonKazama-Hellion f9ed487ae2 feat(about): restore the integrations section with honorific status 2026-06-15 14:40:21 +02:00