34e343d8f2ecf3307549d8485760708cdddfe238
1618
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
34e343d8f2 |
feat(fonts): a handle for the sender and one for the meta line
Two roles need a face of their own, and they need it for opposite reasons. The sender is meant to carry weight. The mockup says 600, and there is no bold face anywhere in the plugin -- the bundled file is Inter-Light and the game's Axis is a single weight. So the weight comes from rasterising the same outline denser, via RasterizerMultiply. That only works on the delegate path: with both font toggles off the game font handle draws and has no such knob, and the sender falls back to leaning on channel colour alone. Deliberate limitation, not an oversight. The meta face goes the other way: smaller, and on a glyph range of about eighty entries instead of the full set. Timestamps and world names are Latin in every client FFXIV ships, so ASCII plus the middle dot covers what this face will ever be asked to draw. A full range would have rasterised the whole CJK block a second time for nothing. Anything translated stays on the body face. Two things in the rebuild path had to change with them. The handles now go up inside a SuppressAutoRebuild block -- without it, one size change meant four separate atlas rebuilds instead of one. And FontsReady checks both new handles unconditionally, unlike ItalicFont which is allowed to be null: a handle that is not ready makes SimplePushedFont push nothing at all, silently, and the first frame after a rebuild would measure the wrong face and write those heights into the row cache. Both font self-tests were extended to match. A handle nobody asserts is a handle that can go missing for a release without anyone noticing. |
||
|
|
6d2bb95528 |
feat(style): name the four type roles
Body, Sender, Header, Meta. Three of them share the base size, which looks like an oversight and is not: the sender is set apart by weight and the header by small caps with wide tracking. Neither of those is a size, and solving them with size instead would turn the log into a ransom note. Only meta steps down, because it is meant to be skipped over rather than read. Factors sit in a static array rather than as consts. The master spec puts typography under theme control, and ThemeTypography is already the declared extension point for it -- a const would wall that off before anyone gets there. No caller yet. FontManager takes the first one in the next commit; that is the one place in this cycle where a piece lands before its call site, and it closes inside the same block. |
||
|
|
58830aecec |
feat(style): the arithmetic behind a type scale
Quarter-point rounding, not whole points. The base size is 12.75pt, so rounding a derived role to whole points would move it further than the step between two adjacent base sizes -- the scale would quantise away the difference it exists to express. The floor is what keeps the meta role legible when someone runs a small base size; below about seven points a timestamp stops being readable at any display scale. |
||
|
|
fc67c0852a |
fix(text): give the wrap calculation the scale imgui actually asks for
CalcWordWrapPositionA takes a scale, and imgui means size / FontSize by that -- the ratio between the size being rendered and the size the face was baked at (imgui_draw.cpp, CalcTextSizeA). We were handing it ImGuiHelpers.GlobalScale. That is the same number today, but by coincidence rather than by design. Dalamud bakes every font at SizePx * GlobalScale and then divides the metrics back down, so g.FontSize / font->FontSize lands on GlobalScale for every handle regardless of its size. The coincidence holds only while one face draws a line. The typography work starting with this cycle puts a second size into the same line, and there the two numbers separate: the wrap would be computed for the wrong size while CalcTextSizeA keeps measuring with the right one. Measured height and drawn wrap would drift apart, and the virtualised clipper plans against the measured value. No behaviour change expected here -- the expression evaluates to what the old constant already was. |
||
|
|
b388dcb2de |
chore(release): close the v1.12.0 cycle
Version to 1.12.0 in the csproj, with the changelog and roadmap entries for the local state. Not published: the public release stays at v1.5.6, repo.json keeps its 1.5.6.0 manifest and all three download links are untouched. The changelog leads with the storage change rather than burying it under fixes. Until this release the unknown-channel failsafe was applied to known channels too, so an installation that had unticked channels and left the failsafe on was storing more than the grid said. From here the grid decides, which means those installations store less than they did yesterday. Nothing already written is touched, but a user who compares the database against last week should find the reason in the notes and not have to guess. Config version 25 is recorded there for the same reason. There is no migration behind it -- the storage rule changed shape, and the stamp says so. Known issues carry the honest tail: the database viewer and the emoji picker are still English, 245 orphaned resource keys remain as the inventory of what the v1.6.0 rebuild lost, and the licence text still contradicts itself between the About tab and the translated resources. |
||
|
|
e0c9efca05 |
i18n: the five spots the smoke test found, and a BOM in the export
Reported from a real pass through the window. The theme categories were a static readonly array, so the five names froze at whatever language the plugin started in and a runtime switch relabelled the entire window except them. Same shape as the layout labels earlier in this cycle; this one got missed because replacing the literals with resource lookups looks finished until you actually switch. The status bar built its counts from English literals -- tab, tabs, msg, tell, tells -- and the privacy pill said "Privacy-First" in all 25 files. The thousands separator follows the user's culture now too, so German reads 1,2k rather than 1.2k. The live preview claims to show what the window will look like. It was showing English channel names next to a translated placeholder, which is worse than either. Channel labels come from ChatType.Name() now and the status slots share the strings with the real status bar. The four mock chat lines stay English on the earlier decision. And the export wrote a byte order mark. Encoding.UTF8 emits one, and a leading U+FEFF makes the JSON invalid for every strict parser -- confirmed against a real export from the game, where python's json.load refused the file. CSV keeps its BOM, because without one Excel guesses the codepage and mangles every non-ASCII name. The self-test that was supposed to catch that read the file with File.ReadAllText, which strips a BOM while detecting the encoding. It reads bytes now. The status bar tests asserted English literals and started failing on a German machine -- they pin a fixed culture now instead of inheriting the locale of whoever runs them. |
||
|
|
fdb1a98519 |
fix(privacy): the cleanup could never be applied, and three more from the audit
The cleanup preview marked itself stale before it could be drawn. The gate bumps a revision on release so a preview cannot survive a wipe; I then made the preview take the gate, so its own release invalidated it every single time and the apply button never appeared. The feature has been shipping non-functional since it was written, with a self-test that asserted the exact bump that killed it. Read-only operations no longer move the revision, and preview and maintenance have their own marks instead of borrowing Cleanup -- which also stops the five-second metadata refresh from expiring previews, and stops the UI announcing "another operation is running: cleanup" during a VACUUM. The JSON export produced invalid JSON. The chat relation kinds were interpolated straight into the output, and interpolating an enum writes its member name, so every message with a recognised relation came out as "source_kind":LocalPlayer. That is the file a GDPR access request goes out on. The self-test wrote a JSON file and never parsed it; it does now. Retention with the limit at zero still deleted. The slider is labelled "0 = never" and the sweep seeded 31 spec defaults unconditionally before reading the user's overrides, so zero still lost free company, linkshell and party history after ninety days -- and the short-circuit written for exactly this case could never be reached, because the map was never empty. A wipe that worked reported that it had failed. VACUUM needs the database to itself, the refilter walks a lazy reader on the primary connection outside the lock, and the two collide -- after the DELETE has committed. The delete paths no longer let that escape: the rows are gone, an uncompacted file is a housekeeping problem, and telling somebody their history is still there when it is not is a different kind of problem. Also: - CSV cells starting with =, +, - or @ get a leading apostrophe. The content is text other people typed into a chat channel and the file exists to be opened in a spreadsheet. - An export that matched nothing no longer replaces the previous one. It used to write its header, move it into place, and then report that nothing matched. Dalamud's save dialog offers no overwrite confirmation to fall back on, so this is the part that had to move. - The retention sweep says so when it loses the race for the gate, and routes its notifications through the teardown check like everything else. |
||
|
|
9ea9e96145 |
fix: close what the style review found, starting with a gate the metadata skipped
The real defect first. RefreshDatabaseMetadata was the one worker of six that never took the shared lock, and its flag was the one of six missing from the tab's busy state. It calls MessageCount, which holds the read lock, so a wipe could start while it was in there -- and the tab would not have known to grey the button, because it could not see the worker. Both halves fixed. The pattern is why: seven near-copies of one worker skeleton, and each copy decided something slightly different. The clear button failed silently when its thread could not start. The most destructive control in the plugin, pressed, and nothing happens, with no way to tell that from a wipe that worked -- while the three harmless workers beside it do report. Maintenance was the mirror: its comment promises refusals are said out loud, and then swallowed the actual failure. Three start-failure paths also bypassed the notify helper that carries the teardown check, three weeks after it was added for exactly that. The database numbers now wait for a real read, like the clear hint already did. Zero bytes and zero messages read as an empty database, not as a number nobody has fetched. SelectionAfterDelete is gone, with its three tests. The accordion has no selection, so its return value went into a discard -- a function answering a question the interface does not ask, with green tests guarding nothing. The project's own self-test README calls that the anti-pattern of record. Six new keys replaced by the translated orphans that already said the same thing. A commit earlier in this cycle is literally called "stop duplicating a key" and these went past it. The duplicate button also had the label "Add", which is the one string out of ninety-four that was never written. Tests: CleanupDeleteTypes had none, and with the failsafe on -- how a fresh config ships -- it is the path every cleanup takes. Four now, including the one that matters: an empty list deletes nothing rather than everything. And a self-test for the gate wiring, which is what would have caught the metadata worker. The unit tests prove the gate works; nothing proved the workers use it. |
||
|
|
e7b76fb21b |
chore(i18n): remove the keys whose features were removed on purpose
Fifty-four keys across 25 files, and a script that can find the rest. The script reports rather than deletes, because a key with no caller is a question and not a verdict: this cycle has twice found one that only described a feature whose button was torn out, and deleting it would have made the restoration cost 25 files of re-translation. The answer lives in the git history of the deletion, not in a grep. So only the ones where that history says the feature went on purpose: the web interface, the settings card overview the sidebar replaced, the save-and-discard model this window does not have, the LiteDB migration dialog, and three one-time announcements for versions long past. Plus nine this cycle superseded itself, including the three notes telling the reader to press Save first. 251 keys still have no caller. That is not a to-do list -- it is the inventory the search pass turned up: timestamp layouts, collapse duplicate messages, the About tab's prose, the honorific glow, the novice network button. Every one of them describes something that used to work. They stay until each has been decided one way or the other, which is the whole premise of this cycle. |
||
|
|
bbfb9fc630 |
feat(tabs): the tab editor is back
There has been no way to create a tab, delete one, reorder them, or
choose what any of them collects since the settings window was rebuilt
in May. The five tabs a config happened to have were all a user could
ever have. Every label for this was sitting in the resources, translated
into 25 languages, with no caller.
An accordion, not the list-and-detail pane the plan sketched. The
settings column is narrow, every other tab in this window is a stack of
collapsible sections, and a split pane inside one of them would be the
only thing here that reads differently without buying anything.
Channels go through the matrix that already existed, unchanged: it knows
the groups, the sub-matrices and the ExtraChat channels, and it is
localised. What it does not know is copy-on-write -- it mutates the
dictionary it is handed -- so it never gets the tab's own. Edits land in
a working copy and are published as one reference swap when the user
leaves the tab, and only if something actually changed.
Saving is deferred behind a dirty flag with a short idle, not
IsItemDeactivatedAfterEdit. That idiom defers for sliders and text
fields, which stay active across frames; a checkbox activates and
deactivates inside one click, so it would fire exactly as often as the
return value and write the config file once per box.
Deleting closes the pop-out first, or the pool keeps a slot bound to a
tab that no longer exists. The last editable tab cannot be deleted at
all: the message list has no empty state. Temp tabs are not editable
here -- their name is a conversation partner and the auto-tell service
owns their lifetime -- so they are skipped entirely.
Also E2: Tab.AddMessage stamps LastActivity for every message now. The
condition that used to gate it filtered on InactivityHideChannels, a
setting belonging to hide-when-inactive, and that feature lost its
reader in
|
||
|
|
3a1b863def |
i18n: adopt the client's word for tell, and translate the appearance tab
Two decisions, both yours. Where Square Enix ships a client in a language, that client's word wins. German says Flüstern, French message privé, Japanese テル, Korean 귓속말, Simplified Chinese 密语. Everywhere else there is no official client and the loanword is what players actually say, so it stays. That meant rewriting the whole corpus in those languages rather than just the new keys, which is the reason the split existed in the first place: forty German values and thirty-seven French ones carried the old word. /tell is untouched, because that is a command and not a noun. The mechanical pass left French with three agreement errors -- "des message privé", "ce onglet", "messages de message privé" -- which is what happens when you substring-replace a language with gender and number. Fixed by hand. Traditional Chinese keeps 悄悄話, which the file already used nineteen times; it has no official client of its own. The appearance tab is translated as well. Thirty keys: the colour editor's groups and buttons, the theme categories, fork and import and export, the font labels and the preview's input placeholder. Theme token names stay English -- WindowBg and TextPrimary are JSON keys, not prose -- and so do the brand strings. 465 keys, 25 files, no gaps, no orphans, no placeholder drift. Still English and not in this commit: the database viewer behind /hellionView, which is a documented user command rather than a developer tool, and the symbol picker. Both are their own block. |
||
|
|
5991f49c59 |
i18n: hold the new strings against the glossary the plugin already had
A language review over all 24 files found that the translation batch had been written as if the file were empty. It is not: eleven years of FFXIV vocabulary and several hundred existing values already decided how this plugin says things, and the new strings disagreed with them. Three said something wrong rather than something unusual: - Norwegian called opacity "tetthet", which is density -- and the same file uses that exact word for compact density, so one word stood for two different settings. - Danish called the brand section "Brand", which in Danish is fire. - Turkish called a manifest a notification, in a window that already has a notifications section. Then the glossary itself. "tab" is zakładka in Polish, tab in Romanian and Danish; "plugin" stays plugin in five languages that had translated it; "pop-out" is a loanword in six. The Shift key keeps its name in Swedish, Finnish and Italian, because that is what is printed on it. Fixing pop-out also shortened the three longest row labels in the batch past the point where SettingRow would have clipped them. Czech was the clearest outright error: the existing file addresses the reader informally throughout, and five new strings switched to the formal form. And a gap in my own previous fix: I had moved four of the five tell strings to the established loanword and missed the fifth pair, so the failed-tell warning still said "Flüstern" while its neighbours said "Tell". 435 keys, 25 files, no gaps, no orphans, no placeholder drift, and every one of the five tell keys now carries the same word in every language. Whether German and French should say Flüstern and message privé rather than tell is a real question -- both game clients do -- but that is a glossary decision for the whole file, not something to introduce through four new keys. |
||
|
|
b3c0ec73ca |
i18n(settings): keep the game's own vocabulary, and stop duplicating a key
A review pass found the translation commit had introduced a split rather than closed one. Every existing string in this plugin leaves "tell" untranslated -- it is what the game calls the thing, in twenty values across the German file alone. My new keys translated it, so the channels tab showed "Auto-Tell- Tabs" and, two rows below, "Flüstern automatisch öffnen". Thirteen of fourteen sampled languages had the same break. The four affected keys now use the established word, which also fixes a German line that had tells "aufgehen" like a door. Turkish said the opposite of what the control does: saydamlık is transparency, the slider is opacity, and 1.0 means fully opaque. Every other language uses the opacity word. Corrected, along with the inactive one beside it. Compact density had grown a second key for a field that already had one. Appearance_UseCompactDensity_Name and _Description were sitting there with no caller -- which is precisely what this cycle exists to fix -- so the chat tab uses those and my duplicate is gone again. The German wizard label for the same field said "Kompakter Density-Modus"; it says the same thing as the other two now. Also: Hungarian called a pop-out window "kiemelt" (highlighted), the project's word is "kiugró"; German called it "ausgeklappt", which is what a menu does; Italian and Polish had a clumsy inactive-opacity label. 435 keys, all 25 files, no gaps, no orphans in either direction. One claim in the previous commit was wrong and is worth stating plainly: it said only log lines, thread names, developer tools and brand strings remained English. The appearance tab is still English throughout -- the theme picker, the colour editor, the font section, the live preview -- and so are parts of the database viewer, which is a documented user command, not a developer tool. That is a separate block, not a footnote. |
||
|
|
83306f1f47 |
i18n(settings): translate the settings window
The settings window was rebuilt in v1.10.0 and v1.11.0 with its labels written straight into the C#. Every section heading and most row labels across five tabs read English in all 25 languages, which the memory notes had accepted as a known backlog. Forty-nine keys close it: seventeen section headings, the rows under them, and the two layout choices. All 25 languages, 437 keys each, no gaps and no placeholder drift in either direction. The layout labels are a property rather than a static array now. A static one would hold whichever language the plugin started in, and this plugin switches language at runtime. What stays English, deliberately: log lines and thread names, which no user reads; the two developer tools behind Shift plus Ctrl+Shift, per the decision that developer surfaces stay English; and the About tab's brand lines, attributions and licence identifiers, which are names. |
||
|
|
6bb020547d |
feat(tabs): groundwork for the tab editor
The two halves that can be settled without a window, before the window exists. Concurrency first, because the editor is the first thing that ever writes SelectedChannels after load. All three channel-filter fields are read without a lock from the pending-message thread, the filter worker and the draw thread, and mutating a live Dictionary while Matches walks it is the standard way to get a wrong answer on somebody else's stack. ReplaceChannelFilter builds the replacements and swaps the references, so a reader sees the old set or the new one and never half of either. It deliberately stops short of making the three writes one atomic step. A reader can catch the new dictionary with the old ExtraChat flag for a single message, which the editor's closing clear-and-refilter reconsiders anyway. Doing better would mean one reference for all three, and all three are serialized fields whose shape the config file already has. Then the index maths, which is where temp tabs make this more than list arithmetic. They live in the same collection, they are not editable -- their name is a conversation partner and deleting one would be deleting a conversation -- and a move has to step over them rather than swap with them, or moving a tab down and back up would not return it to where it started. Reversible in the editable order, which is the order the user sees; the sidebar draws temp tabs under their own headers regardless of where they sit in the list. Twelve facts, and one of them started out asserting the wrong property: that the whole list is restored by a move and its reverse. It is not, and it does not need to be. The last editable tab cannot be deleted. The message list has no empty state, so a window with nothing to draw is not a state to offer. |
||
|
|
8e38e3e805 |
refactor(config): delete what was never read, restore what was only orphaned
Block D of v1.12.0. The line between the two is the whole job, and I got it wrong once on the way: six cache fields on Tab looked dead because nothing writes them, and nothing writes them because AutoTellTabTint and TabTintCache went out with the chat window in |
||
|
|
6829a80ff2 |
refactor(wizard): drop LoadPreviousSession, which never did anything
The wizard asked for it, coupled it to a neighbour, listed it in the summary as applied, and wrote it to the config. No code in the plugin has ever read it. A wizard that collects a decision and reports an effect that does not happen is worse than one that never asked. Its partner, FilterIncludePreviousSessions, has a real reader and stays. The coupling was two-way -- switching this on forced the partner on, switching the partner off forced this one off -- so removing it leaves a single checkbox that means what it says, and the summary line now reports the setting that actually took effect. The self-test asserted that skipping step 3 does not overwrite either field. That assertion is rewritten rather than repaired: it was pinning the null-semantics of the wizard's pending state, which still matters, just with one field instead of two. |
||
|
|
e8d06e05fa |
fix(input): put the right-click menu back on the input field
Reported by a tester: the input field in v1.5.6 had right-click actions,
the current one has no right-click at all.
Correct, and the commit before this one put those actions in the wrong
place. The v1.5.6 menu was ImRaii.ContextPopupItem("ChatInputContext")
bound to the input field itself; I hung the two token entries off the
chat-message menu, where the rest of that window's items had landed.
They are on the input field now, which is also where they are useful:
you insert a token while composing, not while reading.
Two details from the original that the misplaced version had lost:
- The agent pointers are null-checked. Both can be null during a zone
transition, which is exactly when somebody is most likely to be typing
a flag into party chat.
- Inserting refocuses the field and puts the caret behind the token.
Picking from a menu and then having to click back into the field is
the kind of friction that makes a feature go unused.
Hiding the chat is not repeated in the menu. It was in the v1.5.6 one,
but it has its own button two widgets to the right now, and one way in
is enough.
|
||
|
|
3cb20b6f65 |
feat(chat): insert map-flag and item-link tokens again, and finish the resx parity
The two menu items went out with the v1.5.6 chat window in
|
||
|
|
75c4acd19a |
feat(settings): make eleven settings reachable again
Every field here has a reader in the running plugin and had no control in the window. The config file was the only way to any of them. HideChat is the one that mattered. It defaults to on, it suppresses the game's own chat window, and the only other path to it is a right-click item that sets it to true and can never set it back. Anyone who used that item once had to edit JSON to get their chat back. The new toggle reads and writes the same field, so the two agree instead of fighting. The rest, by tab: - Window: the three remaining hide conditions, the preview minimum that belongs to a toggle already on screen, native item tooltips and their offset. - Chat: an emotes section holding the BetterTTV switch, the cache state, and an editor for the blocked-code list that has been in the config since v1.0 with nowhere to edit it. Blocking one code is a finer instrument than switching emotes off wholesale. Plus auto-translate sorting. - General: the failed-tell warning. The game reports a failed tell in the log only, where somebody who is typing does not see it. - Data & Privacy: battle messages. It decides whether battle lines are persisted at all, which makes it a storage decision rather than a chat one, and it sits in front of the whitelist rather than inside it. MaxParallelPopouts stays out. It is read once in the pool's constructor, the windows are registered once, and registering at runtime is explicitly forbidden -- a slider would do nothing until a reload, which is exactly the kind of decoration this cycle exists to remove. Four new strings in all 25 languages; the other fifteen labels were already translated and waiting. |
||
|
|
1bb968211b |
feat(tabs): give pinning a way in, and a way back out
Pinning has been complete since v1.4.7: pools, a cap of five, persistence, logout symmetry, the notification when the cap is hit. The menu that called it went away, and nothing has called it since. That is not only a missing feature, it is a dead end in saved data. A tab pinned in v1.5.6 survives every save and load and permanently occupies one of the five slots, and there has been no way anywhere to release it. Unpin is the reason this task exists. So the context menu grows a pin section for temp tabs, and the sidebar grows the marker that says which rows are pinned -- a small thumbtack in the icon's lower left, drawn from the row's own rectangle so it claims no layout of its own. The unread badge owns the upper right, and badges in this sidebar are where drawing into unreserved space caught this project last. At the cap the item is disabled rather than hidden: that is a state the user can undo by unpinning something, and the tooltip switches to say which. Five translated strings that had no caller now have one. Promote-to-permanent stays out. It was removed on purpose after a tester kept hitting it by accident. Reconnecting every method that lost its caller, without asking why it lost it, would rebuild the problem this cycle is supposed to be cleaning up. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |