Commit Graph
1597 Commits
Author SHA1 Message Date
JonKazama-Hellion 4f3bdc3c7b fix(privacy): translate the channel list and give it back its presets
Reported after a translation pass: the channel list in Data & Privacy
reads English in every language. It was not, as assumed, the client's
own naming -- the list called ToString() on the enum member, so it
showed "FreeCompanyLoginLogout" while Language.ChatType_* sat next to it
holding "Freie Gesellschaft (An-/Abmeldung)" in all 25 languages.

Fixing the label exposed how little else the list had. Six translated
strings written for exactly this control had no caller: the explanation
of what it does, the note that it governs the database and not the chat
window, and three presets -- data minimisation, select all, deselect
all. Eighty-nine checkboxes without a "recommended" button is not a
choice anyone makes.

So the list now carries the same eight groups the export uses, with the
individual channels one fold away. It is the one control that decides
what reaches the database; the two screens describing channels the same
way is worth more here than anywhere else.

The unknown-channel switch gets its description back, and the telemetry
section stops being the last English literal in the tab. It stays a
statement rather than a switch: a toggle would imply there is something
to turn off.
2026-08-18 22:30:12 +02:00
JonKazama-Hellion 9b634f54e9 fix(tabs): stop a tell from a popped-out partner hijacking the main window
Reported from the field: a new tell from someone whose tab is popped out
throws the main window onto the General tab, every time.

The router revealed the tab by activating it in the main window. That
window does not display a popped-out tab -- PickMainActiveTab re-anchors
on the next frame, and it anchors to the FIRST non-popped tab in list
order, not to the one the user was reading. So the reveal did nothing it
intended and threw away the active tab on the way.

Nothing needed revealing in the first place: the tab was already on
screen in its own window. That decision now lives in PlanTellReveal,
next to the pop-out helpers it belongs with, pure and pinned by nine
facts -- the mode, the switch and the popped-out state have eight
combinations between them and only two of them should touch anything.

ActivateTab refuses a popped-out tab outright as well. The router is the
caller that got it wrong, but the invariant belongs to the window: its
active tab is never one that something else is drawing.
2026-08-18 22:25:23 +02:00
JonKazama-Hellion 4f4f5fc86a docs: finish holding the documentation against the code
The first pass through A6 caught the dead file paths and the two GDPR
sections. A review pass walked every remaining claim and found eight
more that no longer describe the plugin.

The privacy notice carries a "last reviewed" stamp and commits, in its
own text, to keeping it accurate for the version it describes. It still
said v1.1.0 after the block that rewrote two of its sections.

Both documents announced two outbound network calls in their opening
paragraphs. The Lodestone font download went away in v1.0.4 and the
notice explains that further down, so the summary contradicted its own
body. There is one call, and it is BetterTTV.

The emote cache is `EmoteCacheV1/`, which the notice states correctly in
one place and wrongly in two others, two lines above a line this cycle
had already touched.

The list of buttons that open a browser named a Ko-fi page that appears
nowhere in the source, an issue tracker and a website that have no
button, and left out the Discord invite and the two Honorific links that
do. In a section that exists precisely to enumerate where a click sends
you, both halves matter.

The README described the pop-out input bar as an opt-in under a settings
section that does not exist. Neither does the switch: PopOutInputEnabled
has no reader, the input bar is unconditional, and the field belongs on
the deletion list. Said plainly instead.

Also: the export narrows by age, not by a date range, in both documents;
the tab is called About, not Info, and it has no per-translator list;
the sections are Colours and Integrations.

Plus a note the users affected by it deserve: the v24 migration switches
the privacy filter off where it was on with nothing selected, and until
now that was only in the log.
2026-08-18 22:14:45 +02:00
JonKazama-Hellion 06ef0bfb1c fix(input): let the arrow keys walk the sent-message history again
Reported against v1.11.0: type a command, send it, need it again, and
up-arrow does nothing. v1.5.6 recalled it.

Everything needed was already in the tree. InputHistoryService holds the
last thirty entries with move-to-newest dedup, CompactInputHistoryNavigator
owns the cursor maths, and both have their own test mirrors. Neither had
a single caller, and the input field never set CallbackHistory, so ImGui
had no reason to raise the event in the first place.

So: the flag goes on, the callback grows a history branch, and TrySend
pushes the trimmed line before it clears the buffer. Editing ends the
walk, otherwise down-arrow after changing a recalled line would jump to
the next entry and discard the edit.

The cursor is per input bar while the history is global. Which line each
window is looking at is not something the others should inherit.
2026-08-18 22:13:41 +02:00
JonKazama-Hellion 0279a1a9d6 fix(privacy): close the gaps three review passes found in block A
The worst of them made the block's own privacy promise backwards.

PrivacyPersistChannels was given a non-empty field initializer so a
fresh config would record conversations only. Dalamud deserialises with
Json.NET's defaults, which means ObjectCreationHandling.Auto: a
collection field that already holds items gets *populated*, not
replaced. Verified against Newtonsoft 13.0.3 -- saved [] loads as the
initializer, saved [Say] loads as initializer plus Say. So the change
would have unioned the privacy-first list into every existing config on
load and switched channels back on that the user had unticked, while
also making the v24 migration unreachable and its self-test vacuous. The
field is empty again and the seeding moved to CreateFresh, which only
runs when there is no config file at all.

Cleanup could delete a channel it had promised to keep. The allowlist
could only name channels that were already in the database when the
preview ran, so an unrecognised channel whose first message arrived
afterwards fell outside it. Where the failsafe is on, the deletion now
names what goes -- known channels that are not on the list -- instead of
what stays. The window closes completely, and a listed channel that
happens to be empty right now is safe for the same reason.

The cleanup preview was the one long operation that never took the
shared lock, while holding an open reader across a full-table scan.
That is precisely the case the lock was written for.

Clearing the history reported success when it failed. ClearMessages
purges the full-text index between the delete and the VACUUM; if that
step throws, the plaintext stays on disk and the user was told it was
gone. It has its own error string now, in all 25 languages.

Also:

- One busy state for the whole tab. Cleanup, clear, maintenance and
  export reach the same store, and per-section flags left two
  destructive buttons live at once. The lock turned that into a refusal
  rather than damage, but a refusal you have to trigger to discover is
  not an answer.
- The gate carries a revision, bumped by every mutating operation that
  finishes. A preview taken before a retention sweep no longer passes as
  current afterwards: comparing it against the settings alone cannot see
  that the rows it counted are gone.
- Database metadata moved to a worker. Checking "is anything busy" first
  is not enough, because an operation can take the lock in the gap
  before COUNT(*) runs, and then the game stands still for a whole file
  rewrite.
- The clear hint stays hidden until the count has actually been read.
  "0 messages are stored" in front of the clear button is a lie told at
  the worst possible moment.
- Refusal notices read the operation once. Guard and name were two reads
  of the same field, so a run finishing in between printed a sentence
  that stopped at the colon.
- The retention sweep cannot start twice. The gate only goes busy once
  the worker reaches TryBegin, and the due-check runs every tick.
- Teardown waits up to five seconds for the store to come free rather
  than disposing the connection under a running VACUUM.
- Maintenance has its own flag and says so when it is refused; reload
  gets the same guard as its neighbour; the breakdown tree keeps its
  open state across a language switch.
2026-08-18 22:13:33 +02:00
JonKazama-Hellion 1987d745d8 docs: hold the documentation against the code
Every claim in the user-facing docs, checked against what the plugin
actually does after A2 to A5.

The two GDPR sections in PRIVACY.md describe features that only exist
again as of this cycle, so they now name the screens they live on and
say what the cleanup does with a channel this build does not recognise.
The export sentence promised a date range; the form offers an age in
days.

PRIVACY.md also named "Show emotes" in Settings as the way to stop the
one outbound call the plugin makes. That switch has had no control since
May, and a documented opt-out that only exists in the JSON is not an
opt-out, so the toggle is back in the chat tab. Both its strings were
already translated.

Dead paths: the README source tree still listed a file deleted in May,
docs/IPC.md cited a window that no longer exists, the first-run wizard
pointed at the same removed file, and the theme authoring guide sent
readers to a Themes tab that is called Appearance.

The changelog lost two double blank lines that were failing markdownlint
and holding preflight red. Preflight is green again.

Historical documents keep their old paths on purpose: the changelog and
the AI disclosure describe what was true when they were written.
2026-08-18 21:51:15 +02:00
JonKazama-Hellion e24ea79302 feat(privacy): reconnect database maintenance and the manual retention run
Two sections that had backends and no buttons.

Database: path, size, WAL size, message count, and a clear button. The
numbers refresh at most every five seconds and not at all while a long
operation owns the store -- MessageCount takes the read lock, and asking
for it during a VACUUM means waiting for the whole file to be rewritten,
on the draw thread. The old version called ClearMessages straight from
the draw thread, VACUUM included; it runs on a worker now.

One line beyond the old layout sits above the clear button: how many
messages are stored, and that exporting keeps a copy. Whoever is about
to throw the history away should be told there is a way not to.

The legacy Chat 2 files only get a block when they are actually on disk,
and the advanced tools only appear when the section is expanded with
Shift held. The message injector is not back: it was deleted with the
tab and writing 10,000 fake messages into a user's real database is not
something to rebuild on the way past.

Retention: an "apply now" button, the running hint, and the last-run
line, which v1.11.0 shipped as an English literal while both strings sat
translated in all 25 languages. Plus reset-to-spec next to the existing
clear-overrides, since the two answer different questions and both were
already translated.

Retention_Apply_Tooltip stays unused and gets a replacement. It ends
with "Save your changes first", and the window it was written for had a
Save button.

Also here, found while wiring the manual trigger:

DbOperationGate.End now takes the operation it releases. It used to
reset blindly, on the reasoning that a worker must be able to release
from a finally without knowing whether it acquired. That is backwards: a
worker whose TryBegin was refused also runs its finally, and a blind
reset there hands away the lock of whichever operation actually holds
it. Worse than no gate, because the refused worker walks off believing
it did nothing while a VACUUM starts under somebody's open reader.
2026-08-18 21:47:50 +02:00
JonKazama-Hellion 1ab7ba8377 feat(privacy): reconnect the retroactive cleanup
The privacy filter only decides what gets written from now on. Whatever
was stored before the user narrowed their channels stays there until
something removes it, and that something has had no button since May.

Two rules shape the section, both because this deletes history and
cannot be undone:

- Without a preview the apply button does not exist. Not greyed out,
  absent. A disabled button is something a user waits for; a missing one
  is something they have to go and earn.
- A preview that no longer matches the settings counts as no preview.
  The old version only recoloured the number and left the button live,
  so a changed whitelist could be applied against counts computed for
  the previous one.

The mapping from the live rule to CleanupRetainOnly is the part worth
reading twice. CleanupRetainOnly takes one set and deletes everything
else, so it can only stand in for the live rule where that rule narrows
something: filter off means nothing is filtered, and an empty list means
a full wipe, which has its own button and its own confirmation. Both
cases now say so instead of offering a destructive action that does not
mean what it looks like.

Inside that, the allowlist is the whitelist itself plus any stored
channel this build does not recognise while the unknown-channel failsafe
is on. Deriving it from the counts instead would delete messages that
arrive on a whitelisted but currently empty channel between the preview
and the apply, and dropping the unrecognised ones would defeat the
failsafe, which exists to hold on to a new patch's channel until the
user has decided about it.

The preview runs on a worker over its own connection. It is a GROUP BY
across every stored row, the old version ran it inline on the draw
thread, and holding the read lock for it would stall UpsertMessage on
the framework thread for the length of the scan.

Cleanup_Help_SavedNote stays unused: it tells the reader to press Save
first, and the window it was written for had a Save button.
2026-08-18 21:38:34 +02:00
JonKazama-Hellion 94af81a961 fix(privacy): let the channel grid actually decide what is stored
IsAllowedForStorage applied the unknown-channel failsafe to known
channels too. Untick Say in the grid, leave "Save unknown channel types"
on, and Say kept being written -- and that failsafe is on by default.

So a config that never met the wizard ran with the filter enabled, an
empty list and the failsafe on, which stored everything while the
filter's own description promised "only messages from allowed channels
are written to the database". The grid was inert for exactly the users
who had not been walked through the wizard.

The rule now reads: on the list, or unknown and the failsafe allows it.
A known channel the user did not pick stays out.

That correction alone would turn "stores everything" into "stores
nothing" for those same configs, so two things move with it:

- Config v24 switches the filter off where it was on with nothing
  picked. Same behaviour as before, stated where the user can see it,
  and one line in the log saying so. A config that does have picks keeps
  them and starts honouring them, which is the point of the change.
- A fresh config seeds the list from PrivacyFirstWhitelist instead of
  starting empty. Privacy by Default was already the documented intent;
  it just relied on the hole to stay usable.

The rule lives in its own type now. Configuration implements a Dalamud
interface, and the build suite cannot load Dalamud.dll -- the runtime
resolves the declaring type before reaching the method body, so even a
static call on it fails. Fifteen cases pin the truth table and the
migration condition; the self-test checks the running config is not in
the state the migration exists to undo.
2026-08-18 21:32:02 +02:00
JonKazama-Hellion d59ee62223 feat(privacy): reconnect the message export
The exporter has worked since v1.4.8. The form that drives it went out
with the old settings window in May, which left PRIVACY.md promising an
access request the plugin had no way to answer.

New section in the data and privacy tab: time range, sender substring,
channel groups, format, and a save dialog. Form state lives in the tab,
not the config -- a filter describes one action, and a stale "last 7
days, sender Mira" reappearing weeks later is a worse start than an
empty form.

StreamForExport now takes a caller-owned connection. The reader stays
open for as long as the file is written, seconds to minutes on a large
history, and chat keeps arriving throughout -- so the primary connection
would be read here and written by UpsertMessage at once, and
SqliteConnection is not thread-safe. Holding the read lock instead would
trade that for freezing the game.

ChannelGroups lifts the eight groups out of the deleted tab and finishes
them: 37 of 89 channels belonged to no group and were therefore
unreachable in the UI. Game Master channels follow ChatTypeExt.Parent(),
so GmTell sits with the other tells rather than under system traffic --
an access request that quietly drops part of what it promises is the
dangerous kind of gap.

Also here:

- OpenSecondaryConnection disposes on a failing pragma. Open can succeed
  and journal_mode=WAL still time out, and with Pooling=false the
  connection then survives until a finalizer reaches it. Affects the
  full-text rebuild worker too.
- StreamForExport builds its logger before the reader, so a throwing
  CreateLogger cannot leave a reader nobody owns.
- The export thread takes the gate itself instead of the caller taking
  it first. Acquiring before Start would strand the gate for the session
  if thread creation failed, and the gate also holds back the sweep.
- Notifications are skipped once teardown has started. The thread has no
  cancellation path and finishing the file is right, but reporting it to
  a plugin that is gone is not.
- Transient widget rows that return their value instead of saving it.
  Writing the config file on every keystroke of a sender filter would be
  both pointless and slow.
- Five translated keys for "another database operation is running", in
  all 25 languages. Two of the four operation names have no trigger yet;
  they arrive with the cleanup and maintenance sections.
2026-08-18 21:25:10 +02:00
JonKazama-Hellion 90bf986f76 refactor(export): read text from chunks, write the file atomically
Two changes to MessageExporter before it gets a caller.

It read SenderSource and ContentSource, the raw SeStrings. TextValue on one
holding an auto-translate phrase reaches SeStringEvaluator, which asserts it is
on the main thread and throws unconditionally when a macro resolves a global
number. An export belongs on a worker, so that would abort it partway and leave
half a file.

The plan called for resolving text in batches on the framework thread. Not
needed: Message.Sender and Message.Content are already-resolved chunk lists --
ChunkUtil turns auto-translate into text at ingest, and the full-text index reads
them exactly this way. Same strings, no evaluator, no thread affinity, and no
batching machinery.

Second, the file handling. The format was validated after the StreamWriter was
opened, so an unknown format left a zero-byte file where a previous export had
been. It is checked first now, and the write goes to a .part file that is moved
into place at the end. A crash halfway used to leave a file that opens cleanly
and is quietly incomplete -- which on the path a GDPR access request goes out on
is worse than an obvious failure.

Almost none of this is reachable from the build suite: ExportToFile takes
IEnumerable<Message>, Message needs SeString, and xUnit cannot load Dalamud.dll
-- even an empty list fails, because the runtime resolves the parameter type
before the body runs. So the format mapping is pinned there and the rest by a new
self-test, which builds probe messages with deliberately empty SeStrings: if the
exporter ever reads them again, the text comes out blank and it fails.
2026-08-18 20:47:45 +02:00
JonKazama-Hellion 22de2de234 docs(db): correct why VACUUM fails against an open reader
The comment said PerformMaintenance inherits a five-second timeout before
throwing. It does inherit that timeout, but it is not what happens here: a VACUUM
on a connection with a live reader fails instantly with 'cannot VACUUM - SQL
statements in progress'.

That is SQLITE_ERROR, not SQLITE_BUSY. Busy handling only covers contention
between different connections, so no timeout applies and no retry helps. The
practical difference matters for the error message the UI will show: the DELETE
has already committed when it fires, so the rows are gone and only the compaction
is missing -- 'deleted but not compacted', not 'failed'.

And PerformMaintenance batches VACUUM, REINDEX and ANALYZE in one statement, so
a failing VACUUM takes the other two with it.
2026-08-18 20:37:40 +02:00
JonKazama-Hellion 6a3bbe2357 fix(ci): let the version check tell a release from a local build
The check demanded csproj, repo.json and every DownloadLink carry the same
version. That is right for a published release and impossible for anything else,
so it went red the moment v1.11.0 closed -- and pre-push blocks on it.

The two states were conflated. repo.json is the distribution manifest, so its
version has to describe what the links actually serve. Claiming 1.11.0 while
every link serves v1.5.6 makes Dalamud offer an update, install the old build,
and offer the same update again on the next launch. Satisfying the old rule
meant building exactly that.

Now: the manifest must always agree with itself and with its links, and the
build must never be older than what is published. --release additionally demands
the three match, which is the mode for cutting a tag and still catches the v1.2.2
burn it was written for.

The link check moved from the csproj version to the manifest version, which is
the pairing that protects users. In the old form a manifest could name a version
none of its links served and pass, as long as the csproj agreed -- the exact
mismatch it now rejects.
2026-08-18 20:28:28 +02:00
JonKazama-Hellion d0eb2934ed feat(db): one gate for every long-running database operation
Generalises the retention-sweep lock, which already solved this for a single
case: it stopped a manual sweep from racing the automatic one, and nothing else.

Export, cleanup and clear need the same protection against each other, and for a
sharper reason. An export leaves a reader open on the primary connection
deliberately outside _readLock, because the enumerator is consumed lazily by its
caller. A VACUUM starting while that reader lives meets an active reader on a
connection Microsoft documents as not thread-safe, and PerformMaintenance sets no
command timeout, so it inherits five seconds before throwing -- after the DELETE
has already committed.

TryBegin refuses rather than queues. Every one of these is user-initiated, and a
wipe that fires minutes after the click is worse than one that declines. End is
idempotent and does not check which operation ends, so a worker that throws
before acquiring can still release from its finally block.

Current is volatile because the draw thread reads it every frame to decide which
buttons are disabled. Blocking on the lock to find that out would freeze the game
for the length of a VACUUM, which is the exact failure this is meant to prevent.

Pure state machine, so the transitions are pinned without a database or an ImGui
frame -- including that exactly one of 64 competing callers wins.
2026-08-18 20:19:27 +02:00
JonKazama-Hellion 125a57167e fix(privacy): purge the full-text index when messages are deleted
messages_fts stores sender and content as plain text, and no delete path touched
it. ClearMessages, CleanupRetainOnly and the retention sweep all removed rows
from `messages` alone, so the readable text of every "deleted" message stayed on
disk.

It was self-sealing. InitFtsReadyCache treats a non-empty index as ready, so
after a wipe the index stayed full, the flag stayed true, and the rebuild that
would have cleared it never ran again.

This is not hypothetical: the retention sweep runs unattended every 24 hours,
so any user with retention on has been accumulating orphaned plain text since
the index shipped. And the plugin says otherwise in two places -- the clear
button promises "Removes all message history. Cannot be restored", and
PRIVACY.md documents targeted deletion as a feature.

Wiping the index rather than deleting matched rows, because message_guid is a
GUID string while messages.Id is a BLOB and the two cannot be joined in SQL. The
index is derived data; it rebuilds from the surviving rows on the next start,
which is both cheaper and provably complete.

CleanupRetainOnly also skips VACUUM when nothing matched, the way
DeleteByRetentionPolicy already did. Rewriting the whole file for zero deleted
rows costs seconds on a large database and gains nothing.

Six tests drive the real store against a real database, since the defect was in
what the SQL did not touch rather than in any computed value.
2026-08-18 20:03:03 +02:00
JonKazama-Hellion 8bf351ba02 docs: correct the untranslated string count
Measured across the whole UI rather than estimated from the settings window:
around 270 visible literals without a resource key, not the hundred the changelog
claimed. The settings window is the bulk of it, but the first-run wizard, the
input bar and the about tab carry their share.

Smoke test for the cycle came back green -- everything works and does what it
says. The untranslated strings are the only known issue.
2026-08-18 19:37:07 +02:00
JonKazama-Hellion f205dd4e54 chore(release): close the v1.11.0 cycle
Version to 1.11.0 in the csproj, changelog and roadmap entries for the local
state. Not published: the public release stays at v1.5.6 and the download links
are untouched.

repo.json goes back to 1.5.6.0. It had been carrying the development version
while all three download links pointed at the v1.5.6 archive, which is a trap
waiting for a merge: Dalamud would offer an update to a version the download
does not contain, install v1.5.6 again, and offer the same update on the next
launch. main is unaffected -- it only ever saw 1.5.6.0 -- but the feature
branches have been inheriting the mismatch since v1.6.0.

The manifest version belongs to what the links actually serve. A local state is
tracked by the csproj and the changelog, which is what they are for.

Testing channel deliberately not opened yet. Publishing 1.11.0 as a test build
would ship a modern settings window alongside a first-run wizard and a message
list that are still stock ImGui -- and 1.10.0 would ship the pinned-tell-history
bug that 1.11.0 fixes.
2026-08-18 19:34:27 +02:00
JonKazama-Hellion d1e12ce866 fix(popouts): restore a way to close a pop-out
Last commit hid the header row when the title bar was on, to stop the tab name
appearing twice. The close button was in that row, and the title bar carries
none of its own -- ShowCloseButton is off because closing has to go through the
pool to release the slot. So a pop-out with its title bar on could not be closed
at all.

Pop-in moves into the input row, alongside the settings and hide buttons that
are already there. It is red, contrast-checked against the button plate it sits
on, and carries a tooltip, since a bare X next to an emoji picker does not say
where the tab is going.

Wired by setter after construction rather than through the constructor: the
window is what the button has to call, and it does not exist yet while its own
input row is being built. Routing it through the graph would close a
factory-callsite cycle MS.DI cannot detect.

The header row is now just the tab name, drawn only when there is no title bar
to carry it.

InputBar_PopIn_Tooltip ships in all 25 languages, including the Designer entry.
New strings get translated with the change from here on, not batched.
2026-08-18 19:21:24 +02:00
JonKazama-Hellion 051fd64857 fix(settings): use the translations that were already sitting there
The window came out half German and half English on a German client. Thirteen of
those labels had a translated resource all along, reachable from no line of
code -- the same defect this cycle has now hit four times.

Most visible: all seven tab names in the sidebar. Settings_Tab_General and its
siblings exist in 25 languages and the sidebar drew "General", "Appearance",
"Chat" as literals. That is the first thing anyone sees in this window.

Plus reduce motion, custom sound volume, show hide button, window opacity, the
window style heading, and the themes folder button.

Deliberately left alone where the wording matched but the context did not.
"Never" exists as a world-suffix option, not as "the sweep has never run", and
"Opacity" exists for a tab rather than a window; reusing either would read
wrong in any language that inflects them differently. Those want their own keys,
not a borrowed one.

What stays English is what has no key at all: the section headings, the keybind
labels, and the descriptions written during this cycle. Roughly a hundred
strings, and adding them means 25 files each -- that is the localisation pass,
not this commit.
2026-08-18 19:15:00 +02:00
JonKazama-Hellion 9c7be4106d feat(popouts): bring the pop-out windows up to the rest
Three things, all visible in one screenshot.

The tab name was drawn twice. The pop-out has a title bar carrying the tab name
and a header row underneath repeating it -- the same string, one line apart. The
header now only draws when the title bar is off, which is the case it was
written for.

The close button was ImGui.Button, so it took the theme's button colours. On
several themes that is a bright magenta plate sitting next to plain text, making
the way out of the window the loudest thing in it. It uses the plugin's own icon
button now: glyph contrast-checked against the surface, danger colour on hover
only.

And the message area gets the same floor as the main log, with the same
restraint -- no accent wash, motes at a tenth. Pop-outs were the last surface
still sitting on flat background while every other window had depth.
2026-08-18 19:09:40 +02:00
JonKazama-Hellion d30922af8c feat(settings): convert the appearance tab, finishing the window
The last six stock collapsing headers are gone, so all seven tabs now read the
same way. Appearance is the one people open most, being where the themes live,
and it was still the odd one out.

Its four components each needed only the heading, so they get a SectionRenderer
rather than the whole widget set -- SettingsWidgets would have handed a colour
editor a Plugin reference it has no use for.

Three of the six could not take a literal key. The colour editor draws its
section seven times with seven titles, so the key becomes a parameter. The theme
picker keys its categories by position, since CategoryMap is a fixed list and
the index survives those names being translated later. And the custom-theme
section takes a fixed key although its label carries a count, or it would reset
every time a theme is imported or removed.

The theme picker's headers also pick up the disabled state properly now. They
sit inside an ImRaii.Disabled while a theme is being edited, and a draw-list
header cannot see that push -- so they had stayed at full contrast while
everything under them dimmed.
2026-08-18 19:07:04 +02:00
JonKazama-Hellion 04f8f1ace8 feat(settings): opaque settings window, contrast applied throughout
The settings window no longer inherits the chat window's transparency.
GlobalStyleScope pushes one opacity for every window in the plugin, so a value
chosen to keep the chat log out of the way was also deciding how readable a
settings dialog is.

The stronger reason is that contrast cannot be computed against a background
that is not there. Behind a translucent window the real background is the game:
a black cave one minute, a snowfield the next. Every foreground measured last
commit was measured against a colour it does not actually land on. Opaque makes
that measurement true.

BgAlpha only reaches the window fill, never the draw list, so the backdrop
cannot observe it -- it takes an explicit override instead of guessing from the
pushed WindowBg.

Contrast now runs through SettingsPalette, so every row and heading gets it
without each call site asking: 4.5:1 for labels and titles, 3:1 for descriptions
and accents. The lower floor on descriptions is deliberate. It preserves the
rank between the two lines of a row, which is why the description is dimmer at
all, while still guaranteeing it stays legible.

The toggle's border is in there too. It is the only thing marking an off switch,
and a border tone that blends into the surface leaves the control invisible.
2026-08-18 19:01:54 +02:00
JonKazama-Hellion 33bcc38581 feat(style): pick foreground colours by measured contrast
The gradient was turned down because text on the lit edge became hard to read,
which fixed the symptom and lost the effect. The real problem is that a theme
picks one text colour while the same text lands on a base surface, a lit edge
and an accent fill, and a value that reads on one can vanish on another. White
iconography on a pale violet accent was the reported case.

So contrast is computed rather than assumed. ColourUtil gains WCAG relative
luminance, the contrast ratio, and EnsureContrast, which walks a foreground away
from its background until it clears a threshold -- 4.5:1 for text, 3:1 for
icons -- and stops at the first step that does, so a colour keeps as much of its
hue as the ratio allows.

Luminance is gamma-corrected now. sRGB is gamma-encoded, so averaging raw bytes
overstates dark colours badly, and nearly every surface here is dark. Mid grey
is 0.216 relative luminance, not 0.5.

The tests caught a real error in the first version: direction was chosen by
whether the background measured below 0.5 luminance. Pale violet sits at 0.39,
counts as dark by that rule, and the function tried to make white whiter. It now
picks whichever end reaches further from the background.

With foregrounds that follow, the gradient goes back up past where it was.

Applied to the sidebar icons and labels and to the segmented control's labels.
The remaining call sites are a polish pass of their own -- this is the machinery
plus the two places that were reported.
2026-08-18 18:51:08 +02:00
JonKazama-Hellion ae9b503776 fix(tells): load pinned tell history after login, not before it
A pinned tell tab came up empty for the rest of the session, reported by a
tester and reproduced from his config.

RehydratePinnedTabs runs from a hosted service at plugin start. It queries tell
history by character, and CurrentContentId is 0 until a character is logged in
-- LastContentId only gets set from the framework tick. The game loads plugins
at boot, so the normal path queries for character zero, finds nothing, and there
was no second attempt: the service subscribed to Logout but not Login.

It defers now when no character is available and completes on the login that
follows. The pending flag keeps a later character switch from appending a second
copy of the history to tabs that already have it.

Two things hid this. Reloading the plugin in a running session, which is what
development looks like, always has a character available. And it only shows up
if you pin a tell tab at all -- the same path for non-temp tabs already handles
the boot case explicitly, one file over:

    if (pluginInterface.Reason is not PluginLoadReason.Boot)
        manager.FilterAllTabsAsync();

Also surfaces FilterIncludePreviousSessions in the Chat tab, which decides
whether the log shows anything from before the current session and had no
control at all -- written only by the first-run wizard, and only if the user
reached step 3. Skip the wizard and it stays false forever. It applies
immediately rather than at next launch, since the user is looking at the window
when they ask for it.
2026-08-18 18:44:54 +02:00
JonKazama-Hellion 57780351d4 feat(settings): convert the remaining four standard tabs
Chat, Channels, General and Data & Privacy now draw the same way Window does. A
half-converted window is worse than an unconverted one: before, all seven tabs
were consistently dated; after the pilot, one looked current and six looked
abandoned, and switching between them made the seam obvious.

Descriptions move out of the help markers and onto the rows. Those strings were
written to be read, and a (?) the user has to hover is where an explanation goes
to be ignored. Several were translated into 25 languages and had never appeared
on screen at all.

Two settings that could not use the standard helpers -- the language picker,
which rebuilds the font atlas, and the keybind mode, whose description depends
on the selected value -- go through a plain Row that hands the caller the control
column and leaves the save logic alone.

Privacy filter labels now come from the resources that already existed for them,
same defect as the channels tab last round: strings present, translated, and
reachable from no line of code.

Four more constructors take a TokenResolver, so this wants the DI smoke pass.
Only the appearance tab still draws stock collapsing headers; its four
components are their own block.
2026-08-18 18:23:34 +02:00
JonKazama-Hellion 7df18bd552 fix(style): shorten the ramp instead of layering it
The dithering attempt made it worse and the reasoning was backwards. Four layers
at a quarter alpha each carry a quarter of the steps each, so every layer bands
more coarsely than the single ramp did, and blending four coarse ramps adds
interference on top. Reverted.

The real variable is distance, not layer count. A ramp has as many steps as it
has distinct alpha values, so stretching it over the full height of a pane gives
each step a stripe twenty-odd pixels tall, which the eye sharpens into
scanlines. Running the same ramp over 190 pixels puts those steps a few pixels
apart, where they read as a falloff.

So: light fading down from the top edge, shadow gathering at the bottom, and a
flat surface in between. Constant alpha cannot band at all, which leaves most of
the pane immune by construction.
2026-08-18 18:14:02 +02:00
JonKazama-Hellion 05b203c85c fix(style): dither the surface tint so it stops banding
"Horizontal scanlines" names it exactly. An alpha ramp from 0x34 to 0 has 52
distinct values, so across a 600px pane each one owns a stripe about 12 pixels
tall, and the eye reads those stripes as scanlines. Raising the contrast only
buys more of them, thinner.

So it dithers. Four ramps at a quarter alpha each, every one ending slightly
lower than the last, so they share a starting colour but run at different
slopes. Their step boundaries land on different rows, and where one layer has
stepped up its neighbours have not, which puts the blend between two quantised
values. Same total tint, four times the effective resolution, three extra draw
calls.

Layers are staggered by slope rather than offset on purpose: shifting them would
leave a gap at the top where fewer layers overlap, which reads as a bright band.
That trades one artefact for a worse one.

The tint is roughly twice as strong now, which the settings pane wanted anyway,
and strength is a parameter so the chat log can stay at 45% of it.
2026-08-18 18:09:46 +02:00
JonKazama-Hellion c1f1c0563c fix(style): tint the surface instead of repainting it
Both remaining complaints had one cause. The window paints its own background,
and the backdrop was filling the same area a second time. Two stacked layers
turn a translucent window solid, which is the density over the chat log.

And an opaque fill has to carry the entire gradient by itself. That is where the
banding came from: a shallow ramp across an opaque surface crosses so few 8-bit
values that each one covers a visible stripe. Nothing sits underneath to break
them up.

So it tints now. Near-transparent white at the top, near-transparent black at
the bottom, straight over whatever the window already drew. The window colour
stays visible, the ramp only shades it, and the game showing through disperses
what little stepping is left. The sidebar does the same with a flat black wash
rather than a repainted darker surface.

Motes are roughly half as bright everywhere, and the chat log takes 10% of that.

DrawVerticalGradient stays for the segmented control, which paints a surface
that genuinely is its own rather than one already drawn underneath.
2026-08-18 18:05:11 +02:00
JonKazama-Hellion 8b96ffb2ec fix(style): let the backdrop follow the window's own opacity
Three problems from one screenshot, and the same root cause behind two of them.

The pane painted at full opacity regardless of the window it sat in. BgAlpha
only ever reaches WindowBg, so a draw-list fill ignores it entirely, and a
deliberately translucent settings window came out as a solid block beside a
translucent chat window. The backdrop now reads the alpha out of the resolved
WindowBg colour and carries it through the gradient, the accent wash and the
motes.

The banding is 8-bit quantisation, not a rendering fault: a gentle ramp across a
tall surface crosses so few distinct values that each one covers a visible band.
Halving the range leaves fewer and fainter steps, and the translucency now
underneath them breaks up what remains.

Motes over the chat log drop to 18%. A settings page is read in glances and can
carry motion behind it; a chat log is read line by line, where anything drifting
behind the text competes with it. Intensity is a parameter rather than a second
particle system, so the two surfaces share one implementation.
2026-08-18 18:01:07 +02:00
JonKazama-Hellion 8ebed6846d feat(style): put the chat log on the same floor as the settings
The motes were too subtle to register, so: seventy instead of twenty-four, wider
radius range, and each one is a soft halo with a brighter core rather than a
flat disc. At this size a single filled circle reads as a speck of dirt on the
screen; the ring around it is what makes it look lit.

The backdrop moves into its own component and the chat log gets it too. Two
windows in one plugin looking like two separate products was half the reason the
settings window read as untouched -- the chat had been reworked in v1.10.0 and
the settings had not, and no amount of work inside one window closes that gap.

No accent wash over the message list. It works on a settings pane, where the top
of the surface is a heading; over a chat log the oldest visible messages would
sit in a tinted band and read as highlighted.

Registered transient rather than singleton, so two surfaces on screen own
separate mote sets instead of sharing one drift pattern -- which would be
visible the moment a popout sits next to the main window.

MainWindow's constructor changed, so this needs the DI smoke pass.
2026-08-18 17:55:44 +02:00
JonKazama-Hellion 76416afe3a feat(style): depth for the settings window
Brief was a mix of Lightless and Character Select+, modern without being
overloaded, and the gradient allowed to show more.

The content pane gets a stronger gradient plus an accent wash falling from the
top edge to about a third of the height. Without it the pane was a neutral box
that happened to sit inside a themed window; now the theme reaches the surface
being read.

Ambient motes drift up behind it. Procedural rather than the image sequence
Character Select+ uses, so there are no assets to ship and they take the theme's
accent instead of whatever was baked into a PNG. Fixed count, fixed arrays,
seeded once, so the layout is identical every session and nothing allocates per
frame. They advance once per frame regardless of how many surfaces call in, and
ReduceMotion skips them entirely -- same contract HoverState already honours.

The tab sidebar was the last stock-ImGui element and had started to look it. Its
entries are drawn now: darker plane than the content pane so the two read as
separate surfaces, a chamfered plate with a solid leading bar for the active
one, hover fading between muted and full text.

Its icons go through the draw list with a font pointer taken outside the scope,
rather than being drawn inside a FontAwesome push. That atlas has no ASCII
glyphs, so any label caught inside such a scope renders blank -- which has
already happened twice in this plugin, once in the tooltips and once in a badge.
2026-08-18 17:50:32 +02:00
JonKazama-Hellion ced7ea0c55 feat(style): headings by typography, surfaces by gradient
Two rounds of screenshots said the same thing twice: the section headings read
against blue themes and vanished against violet ones. The cause was that their
only distinction from a normal row was a fill colour, and no fill works in every
palette.

Headings are typography now. Small caps with wide tracking, no plate, no bar,
followed by a rule that fades out along its length instead of stopping dead. The
weight comes from letterforms and whitespace, which look the same in every
theme. 18px of air above each one does the grouping that the bar used to fake.

ImGui has no letter-spacing, so DrawTrackedText renders one glyph at a time
through a stack buffer. That technique is documented in Character Select+, whose
Boutique style layer solves exactly this problem the same way.

Row separators are off by default. A rule under every row turns a settings page
into a ledger, and the hover fill already marks where a row starts and ends.

Surfaces get vertical gradients, derived from a single theme tone rather than a
hardcoded pair, so they follow whatever the active theme sets. Lerp rather than
a brightness multiplier: these surfaces sit near black, where scaling a channel
of 12 by 1.14 lands back on 13. That is what LerpTowardBlack is for.

The selected segment finally uses DrawSlipPolygon, which has been sitting in the
plugin unused since it was written, and its highlight is a second, shorter
chamfer rather than a clipped gradient -- PushClipRect is rectangular and would
have squared the cut corner straight back off.

Reference note: Lightless and Umbra were read for approach only. Both are
AGPL-3.0 and none of their code is here; a vertical gradient is common
knowledge, their implementation of it is theirs.
2026-08-18 17:42:02 +02:00
JonKazama-Hellion 2f4518a6b1 fix(style): give the settings pane the contrast it was missing
The converted tab had the right structure and no legibility. Five separate
causes, all of them contrast rather than colour choice.

The content pane never set ChildBg, so it inherited whatever showed through the
window, and the window is translucent by default. Settings text was sitting on
moving scenery. It gets the surface tone now; the window's own opacity still
applies on top, so the glass look survives.

Section headings had no ground of their own and floated between the rows at
roughly their weight, which meant the tab had lost its grouping entirely. They
get the raised surface, and their accent bar now runs the full height instead of
stopping at the title line.

An off toggle was filled with the same surface tone as the row behind it and
read as empty space. It gets an outline that fades out as it turns on, where the
filled track carries the shape by itself.

Two colours could not be picked statically at all, because themes here range
from near-black to pastel: the label on a selected segment, and the knob on the
track. Both now derive from the luminance of what they sit on, so neither can
end up light-on-light. That is what ColourUtil.OnColour is for.

Descriptions drop from TextMuted to TextFaint. Level with the label they made
each row read as two settings rather than one with an explanation.
2026-08-18 17:13:43 +02:00
JonKazama-Hellion 5a4c3b6707 feat(settings): convert the window tab to the styled widgets
The first tab that actually looks different. Everything here was stock ImGui:
framed collapsing bars, checkboxes with the label trailing on the right, and
sliders glued to the left edge with their name behind them.

Now: section headers with an accent bar, rows with the label on the left and the
control right-aligned in its own column, sliding switches instead of checkboxes,
and a segmented control where two radio buttons used to pretend to be two
settings when they are one.

Three of the four widgets built in this cycle had no call site outside the debug
gallery. That is the exact defect that triggered v1.10.0 -- five tools built and
never wired up -- and the spec rule written afterwards says no widget without a
call site in the same cycle. This closes that on the pilot tab; the remaining
five follow one at a time.

The row helpers live in SettingsWidgets so the other tabs get them unchanged,
and the theme lookups go through SettingsPalette, cached per frame: twenty rows
would otherwise resolve the same five tokens twenty times over.

Section keys are u8 literals rather than the visible titles. ImGui's own storage
keys collapsing headers off the label, which would reset every section's open
state on a language change and merge two sections whose titles translate alike.

Note WindowTab's constructor gained a parameter, so this needs the DI smoke pass
before the next tab follows.
2026-08-18 17:07:39 +02:00
JonKazama-Hellion 18d43f9afa fix(emotes): survive BetterTTV's shared-emote endpoint going private
/emotes/shared/top now answers 403 with {"message":"unauthorized"}. Nothing
checked the status code, so that object was handed to a List<Top100>
deserializer, which threw on the very first character on every plugin start.

The throw escaped the whole loader, so the 65 global emotes -- fetched
successfully one call earlier -- were discarded along with it, and State went
back to Unloaded, arming the same failure for the next trigger.

A failed page now stops paging and keeps what the global endpoint returned. The
global call itself still throws when it fails, because without it there is
nothing to keep.

Two smaller hazards on the same path: an empty page would have made Last() throw
rather than end the loop, and a null deserialization result was dereferenced
outright. Both end the loop now.

Logged as a warning, not an error. This is a third-party endpoint changing its
access policy, not a fault in the plugin, and it does not need a stack trace in
the log at every launch.
2026-08-18 17:01:10 +02:00
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