Skip to content
Pokered Save Editor 2
Pokemon Red & Blue save file editor - Qt 6 C++/QML
Loading...
Searching...
No Matches
2026-07-11 – Session Log

Newest first. The keyboard redesign is the last thing that happened today.


Keyboard, round three: Caps is a page, and the whole look got fixed

Twilight: "caps lock and holding down shift are 2 ways to get page 2, so why are there different page 2 depending on caps lock, i think something got crossed."

Nothing was crossed — but the model was wrong, and her confusion was the bug report. Caps-affects- letters-only (the physical-keyboard rule I'd just carefully implemented) produces a layer that is not one of the 8 pages: uppercase letters over an unshifted number row. The page strip therefore couldn't name it, so the deck looked like it was lying about where you were. A model the UI can't display is a bad model, however correct it is. Caps now locks the Shift page entire — Shift inverts it, Ctrl/Alt ignore it — and every state the deck can be in is exactly one page again. The trade (the punctuation row rides along, so a digit means tapping caps off) is visible and rare; digits aren't in-game-legal in a name anyway.

The look, diagnosed

"colour was supposed to be visible at a glance, not only on mouseover", "keyboard looks really bad but i cant place my finger on why", "the colours on the top and bottom are really ugly and the top and bottom bar is way too tall", "you couldnt see the key text upper right corner", "couldnt you fill in at least the regular keys around the keys you drew". All fair. Every cause had a name:

  • No figure/ground — light caps on a light chassis on a light pane. Now a dark slate body with light caps, and dark structural keys with light text, so keys that type and keys that do separate instantly.
  • The category colour did nothing. A 6% wash behind a 35% border is invisible until you hover it — which defeats the entire point of having a colour legend on the left. Real washes now.
  • The key legend was unreadable — 9px of mid-grey at 75% on a near-white cap. That legend is the whole "just type it" affordance; it now scales with the key, at full opacity, in a dark shade of the cap's own colour.
  • It didn't look like a keyboard. 36 caps floating in a block is roomier than a real layout and reads worse, because the shape you recognise comes from the ragged edges as much as the letters. The deck now draws the full ANSI silhouette — ` - = Tab [ ] \ ; ' , . / Win Menu — as dead keys: muted, inert, unclickable, pure silhouette. Costs the real keys ~10% of their size; the deck scales, so a wider window gives it back.
  • The spacebar looked disabled because it was drawing the Space tile, which is correctly blank. It says "Space" across it now.
  • The chrome was eating the page. A 132px header and a 119px footer of washed-out lighter(accent, 1.5) blue, in a 480px window — the keyboard, the entire point of the screen, got whatever was left. Both are now clean light surfaces with a hairline divider, at 88 / ~104px.

ctest green (74/74). v0.16.2-alpha.


Keyboard, round two: lowercase base, a real Caps Lock, and two modes

Twilight, on the finished deck: "its counterintuitive to have shift lowercase, i get what you did you did exactly what i asked you but i only just now realized that little thing. a-z should be without shift and uppercase with. Besides that dont just do a quick hotswap, you may actually have to replan things."

She was right, and the reason matters. I'd put uppercase on the base layer because Gen 1 names are all-caps — which sounds like good reasoning until you notice it makes the deck contradict every keyboard its user has ever touched. "Gen 1 names are uppercase" is an argument for a good Caps Lock, not for inverting the alphabet.

And it genuinely wasn't a hotswap. Flipping the two letter blocks is trivial; what it forces is:

Caps Lock, done properly

The tempting shortcut — "Caps = latch the Shift page" — is wrong, because the Shift page also carries the punctuation row: with caps on you couldn't type PIKA2 without unlocking. So Caps Lock is a real Caps Lock, with the three rules everyone already knows:

  • letters only (the number row keeps typing numbers),
  • ignored under Ctrl/Alt (Ctrl+B is bold B, caps light or no caps light),
  • inverted by Shift (caps + Shift = lowercase).

That means the deck now honestly shows two pages at once — uppercase letters above a digit row — which is precisely what a keyboard does. The rules went into C++ (FontKeyboard::pageForKey) so tst_font_keyboard could pin every one of them, rather than being three ifs buried in a QML binding.

One real wrinkle: Qt gives no portable way to read the caps light. If it was already on before the screen opened, the deck would start out of step. The fix isn't a hack — event.text is the OS's own answer (it has already folded in caps and shift), so the first letter you press tells the deck the truth and it corrects itself.

Modifiers: momentary, unless you click them

Also from Twilight: physical modifiers must not latch — hold Ctrl, the page flips; let go, it drops back. Clicking an on-screen modifier cap (or a page button) still latches, because a mouse can't hold a chord and click a key at the same time — and because a latched page is the only way in when the OS eats the chord.

Two modes, and the screen says which

The old "click into the box and the deck quietly stops listening" was a hidden mode. It's now explicit:

  • Keyboard mode — the deck is live; the field is a read-only display of what it's building (no caret it won't honour); Backspace removes a whole tile.
  • Edit mode — the pen turns the field into an ordinary text field (caret, selection, Ctrl+C/V/Z, character-wise Backspace) and the whole keyboard fades out and goes dead. The pen becomes a check (apply) and a cross (discard): an edit is something you commit or throw away, never something that half-happened. Nothing typed there reaches the name until the check.

The header names the mode in words, so nobody has to infer the rule from an animation.

Caught by the screenshot pass, again

The edit-mode shot came back showing keyboard mode. The screenshooter had found the item by its editMode property — but NameFullHeader re-exposes that property and, being an ancestor, was found first; invokeMethod("beginEdit") on it did nothing and the shot silently lied. Finding it by its method instead fixed it. (A screenshot harness that can quietly photograph the wrong state is worse than no harness, so: find things by what they can do, not by a property name that may be re-exported.)

Also from the shots: the detail pane's idle hint elided its last line into a trailing "..." that read as a bug — cut to three single-spaced lines that fit the 175px pane.

ctest green (74/74). v0.16.1-alpha.


The full keyboard, rebuilt as an actual keyboard

Twilight: "i want to totally redesign the fullscreen keyboard screen its pretty bad and its never been good." Her idea, in her words: an outline of a real ASDF keyboard, each key holding a tile instead of a letter, with the key it's bound to printed superscript in the corner so you can either click it or just type it. Pages via Shift/Ctrl/Alt combinations, page buttons above, a colour reference where the category filters used to be, no tilemap, no Grid/Tileset toggle. And: "intelligently map out symbols to keys dont just dump them in order... do a comprehensive planning first."

So the plan came first: plans/full-keyboard-redesign.md.

The arithmetic that makes the whole design work

255 tiles. 36 alphanumeric keys (26 letters + 10 digits). That needs 8 pages — and Shift/Ctrl/Alt give exactly 8 combinations. One page per chord, nothing left over, nothing arbitrary. The 255th tile (Space) rides the real spacebar, where every human already expects it.

The mapping doctrine (this was the actual work)

Not a linear fill. Three rules, in priority order:

  1. Identity beats everything. Page 1 is A–Z + 0–9; page 2 (Shift) is a–z — exactly where a keyboard puts them. There is nothing to learn. Page 2's number row even keeps the real shifted symbols the game has: Shift+1 = !, Shift+8 = × (the * key = multiply), Shift+9/0 = ( ).
  2. Mnemonic beats ergonomics. The key legend is printed on the cap, so a memorable key beats a comfortable one: 's on S, 't on T; bold B on B (Ctrl+B — the same thing it means in every other program); <player> on P with <rival> on O right beside it ("opponent"); <pc> on C; <dex> on X. The six box-frame glyphs are laid out as a box on the keys (Q ╔, W ═, E ╗ / A ║ / Z ╚, X ╝) and the three cursor arrows take the right home keys.
  3. Ergonomics breaks ties. Home row first, then upper, then bottom, then the number row.

Pages run in the category order, so the four cheapest chords hold every Normal, Single-Char, Multi-Char and Variable tile — everything you'd ever legitimately put in a name — while Pictures and the Control codes that glitch a name sit behind two- and three-key chords. You cannot hit <end> by accident.

Getting it provably right before drawing a pixel

The map is C++ (mvc/fontkeyboard.cpp) and tst_font_keyboard (39 cases) pins it. The failure modes here are all silent: a tile on two keys, or — far worse — a tile on no key, unreachable forever, which would never surface as a crash or a warning. The test asserts every tile 1–255 appears exactly once across the 8 pages + the spacebar, that no page collides or overflows, that every code resolves in FontsDB, and the identity contract (page 1 A is A, Shift A is a, Ctrl B is bold B…).

It immediately earned its keep: pageFor() returns a modifier MASK (shift 1, ctrl 2, alt 4), so Alt is page 4, not "the 4th page" — my page table was indexed by reading order and had silently swapped two entire pages. Now the index is the mask, and the human reading order is a separate pageOrder that only the page strip uses.

Two QML landmines, both silent

  • id: top + a Repeater delegate. Inside the delegate, every top.* read back undefined — no warning, no ReferenceError, a clean tst_qml_screens. The page strip's chips got a NaN width and rendered as nothing at all; the active-page highlight was always false. Renaming the root to strip fixed it instantly. (The old SearchResults.qml used id: topz — that odd name was almost certainly this same bug, worked around years ago.)
  • Two files both rooted id: top. KeyboardDeck instantiating KeyCap — the delegates' bindings resolved against the cap, not the deck. 108 QML warnings and every cap blank. tst_qml_screens caught this one, which is exactly why it exists.

Both now in reference/qt-patterns.md.

What the screenshots caught (the mandatory review, working)

Screenshot review found four real defects that no test could:

  • the deck overflowed its column and painted on top of the legend and the detail pane (my key-unit constants under-counted the deck's true width — the number row is 13.3 key-units wide, not 12.6);
  • the page strip clipped at both ends (eight named chips don't fit a middle column at the app's 750×480 default — the strip now spans the full body width);
  • the Codes page was unusable mush: <player>, <trainer> and friends expand to 7+ characters and rendered wider than their keys, smearing across neighbours. Those caps now show the bare code and the detail pane renders the real expanded glyphs at a readable size;
  • the detail pane's text ran off the bottom edge into the footer.

Also spotted from a shot: switching page under a stationary mouse left the detail pane describing the tile that used to be under the cursor (no enter/exit fires) — KeyCap.onInfoChanged re-announces now.

Performance: one sheet, not 36 requests

Every image://font request rebuilds the whole tileset. 36 animated keys × an 8-frame cycle would have melted the UI — the same cost that once froze the hover tooltip. So the deck requests the whole 16×16 tile sheet once per frame and each key clips its own 8×8 cell out of it; same URL for every key, so QML's pixmap cache hands out one pixmap. One timer on the deck drives all 36 caps.

Also

Backspace deletes a whole <code>, never one character out of the middle of one. A key that won't fit shakes the name field instead of silently doing nothing. The name box stays a real text field (click in for Ctrl+C/V/Z; the deck dims its key legends to show it's handed over the keys). The category filter became a colour legend — the deck shows everything, so there's nothing left to filter. Gone: the chip list, the filter sidebar, the raw tilemap view, the Grid/Tileset toggle, and FontSearchModel.

Full ctest green (74/74, including byte-fidelity and visual regression). v0.16.0-alpha.


Earlier today: the Market

A long UI-and-economics session on the Market: finished the trainer-card polish, built the Exchange sub-tabs (Currency / Healing / Custom) with real item<->item trading, reworked the dropdowns after feedback, found and fixed a serious pricing bug on the way out the door, shipped v0.15.2-alpha, and finally gave the exchange the unit tests it should have had before it shipped.

Also a batch of standing process changes from Twilight (manual releases, mandatory screenshot review, notes-over-memory, a fast-dev harness loop) – all now written into CLAUDE.md and the notes rather than living in an AI's head.


The pricing bug (the important part)

The item exchange priced the trade per step: every item you asked for was separately rounded up to a whole number of the item you were paying with, and each one refunded its own leftover as money.

Twilight caught it with one sentence: "If they're asking for 3 fresh water at 200 each then 2 potions can be spent exactly and no refund is necessary." She was right, and the old code was badly wrong:

old (per step) new (whole trade)
3 Fresh Water (₽600) paid in Potions (₽300) 3 Potions spent, ₽300 refunded 2 Potions spent, ₽0 refunded
3 Fresh Water paid in Super Potions (₽700) 3 Super Potions, ₽1,500 back 1 Super Potion, ₽100 back

The fix: round the total up once, not each step, and refund only that single leftover. An evenly dividing trade now costs nothing extra and refunds nothing – and the rate line under the button drops the +₽0 rather than advertising a refund that isn't happening.

perAGive / perARefund / perBGive / perBRefund (the four per-step rates) were replaced by giveFor(dir, steps) / refundFor(dir, steps). The preview, the "+" gating, and checkout() now all call the same pricing functions, so they structurally cannot disagree about what a trade costs.

Process lesson: this shipped-adjacent bug was in the code for two commits and survived a live screenshot review, because the screenshots looked fine – the numbers were self-consistent, just wrong. Eyeballing a UI does not verify economics. The unit tests written at the end of the session (below) are the actual guard, and they should have existed before the feature was released, not after. See notes/plans/testing.md.

Exchange sub-tabs (the feature)

The Market's Exchange tab now has a sub-tab bar:

  • Currency – the existing money<->coins converter, unchanged, just moved under the tab.
  • Healing – trade healing/drink items; opens on Potion ⇄ Fresh Water.
  • Custom – trade any two items.

Each side is an item dropdown + a live count + a + button; steps accumulate, the after-counts and money are previewed, and nothing is written until Checkout. Values come from each item's money buy price (Twilight's call: it's the price players see in-game most often, so it reads as fair). Items are counted and written across the bag + PC storage combined.

Driven by a new ItemExchangeModel (brg.itemExchangeModel) over a single net axis – the same shape as the money<->coins coin axis it sits beside. Backed by new ItemStorageBox helpers (capacityForInd() / addAmount() / removeAmount()).

The dropdown rework (asymmetric sides)

First cut restricted both dropdowns to items you already own, which made the feature far less useful than it should be. Reworked so the sides are asymmetric:

  • Left = what you GIVE – only the items you actually own.
  • Right = what you GETevery exchangeable item, owned or not.

The catch: listing every item would be a trap if you could select one you can't pay for. So the get list flags what your stock can't cover and the dropdown greys those out (with 25 Antidotes = ₽2,500, Calcium / Carbos / Full Restore render dimmed and unselectable).

That greying is not just polish – it's what makes Twilight's "never have a case where both buttons are greyed out" rule structural rather than a special case: anything you can select is by definition affordable, so its "+" is always live. Once a step is queued the opposite "+" comes back too, so you can always walk a trade back.

The Healing default prefers the potion family in strength order among the items you actually hold (Potion > Super > Hyper > Max > Full Restore, then the rest) – a save with 8 Super Potions and no Potions opens on Super Potion, never on Antidote just because it sorts first.

Trainer card (earlier in the session)

  • [dice | trash] action groups on Money / Coins / Playtime (the shared RandomButton gained an optional trailing clear segment); Enabled / Paused moved out of the hover overflow menu onto the card as two separate toggles (a connected segmented group wrongly implied mutual exclusivity).
  • A titled "Playtime" grouping box; card widened 500x250 -> 600x310.
  • Alignment pass: the field [dice | trash] groups now share the same random-column and delete-column as the Playtime group's buttons.

This is where the mandatory-screenshot-review rule came from. The playtime clock was overlapping the trainer artwork and it shipped, because I didn't actually look at the render. Manual screenshot review is now a by-default step on any UI change – see CLAUDE.md and reference/ui-patterns.md.

Tests (written last – should have been first)

New tst_item_exchange (14 cases, projects/tests/mvc/tst_item_exchange.cpp), pinning the parts a user cannot verify by eye and that touch their save:

  • The whole-trade pricing rule, including Twilight's exact example (3 Fresh Water = 2 Potions, no refund) and the uneven case (4 Fresh Water = 3 Potions + ₽100, not 4 Potions + ₽400).
  • The refund is always the single rounding leftover – 0 <= refund < the given item's price (a full unit of change would mean we took one item too many) – and money never goes down.
  • checkout() writes exactly the preview, spilling across bag + PC storage (bag drained first).
  • The give list is owned-only, the get list is everything, neither offers the other side's pick.
  • Unaffordable targets are flagged (and become affordable when the stock grows).
  • The two "+" are never both disabled on any selectable pair.
  • The Healing default is Potion ⇄ Fresh Water, and prefers a potion over a status heal.

A priceData_isWhatTheseTestsAssume() guard asserts the Gen 1 buy prices the worked examples rely on, so a future items.json edit fails loudly there instead of confusingly inside the arithmetic.

Full ctest green.

Process / harness changes (standing)

  • Releases are MANUAL now. Commit + push to dev freely; main only moves when Twilight says "ship". Green is necessary but no longer sufficient.
  • Manual screenshot review is mandatory on any UI change – capture and actually scrutinise the image (overlaps, alignment, spacing, clipping) before saying it's done.
  • Notes, not "memory." Project knowledge goes in notes/, not an AI-side memory store. Everything previously stashed there is now in context/collaboration.md.
  • No screen clicks. The DEBUG automation harness (--hot --sav --screen, TCP 127.0.0.1:8766) drives the app instead – see reference/dev-harness.md. The app and build terminals stay backgrounded unless they need attention.

Shipped

v0.15.2-alpha (main @ ac79c0a) – release, pages, tests and lint all green; GitHub Release published. The tst_item_exchange suite landed on dev immediately after and rides the next release.