From 1ecc7489d393d40a76f49581b0931339a96612e5 Mon Sep 17 00:00:00 2001 From: LynxTWO Date: Sun, 23 Aug 2026 15:36:30 -0400 Subject: [PATCH 1/4] Stop a drag off a key from tearing through the depth budget Dragging a few hundred pixels off a key while holding it looked fine on long keys and got progressively stranger the shorter the key was. That is a real defect, not taste. The tilt term divides the press offset by the key's own half-diagonal, so a press point off the key drives that ratio past 1 with nothing stopping it - 500 DIP off a 40x30 key reaches 20 - and tilt scales on it directly. Worst displacement measured 14.79 DIP against a 6.0 budget, and it worsens as the key shrinks because the same drag divides by a smaller half-diagonal and projects onto a shorter reach. Long keys are not exempt, just slower to break. The model gains the two terms it was missing, both physical. A finger cannot load a point that is not on the cap, so contact clamps to the key's rectangle. And sliding a finger off a key you are holding transfers the pressure off it, so the load eases away over 14 DIP and the key rises. That second one also stops the key lying about what happens next, since releasing out there does not activate it. Drag back on and it presses again. The old budget test only ever pressed points inside the key, which is why it never saw this. Six tests now cover the drag. --- .../Controls/SoftBodyModel.cs | 56 +++++++- .../SoftBodyModelTests.cs | 122 ++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/src/CUETools.Linux.App/Controls/SoftBodyModel.cs b/src/CUETools.Linux.App/Controls/SoftBodyModel.cs index 6e5a222..93bc561 100644 --- a/src/CUETools.Linux.App/Controls/SoftBodyModel.cs +++ b/src/CUETools.Linux.App/Controls/SoftBodyModel.cs @@ -55,11 +55,59 @@ public static class SoftBodyModel /// read as a slab on a spring rather than a lever on a pivot. private const double PlungerReach = 0.55; + /// How far past the key's edge a held press keeps handing its load + /// over, in DIP. Slide a finger off a key you are holding and the pressure + /// transfers off the cap over roughly a fingertip's width, not instantly + /// and not never. Absolute for the same reason the dimple is: how rubber + /// lets go does not change because the key is wider. + public const double DisengageBand = 14.0; + + /// The point on the cap the finger actually bears on. A finger + /// cannot load a point that is not on the key, so once the pointer leaves, + /// contact stays at the nearest point on the surface: it is sliding along + /// the edge, not pressing thin air an inch away. + /// + /// Leaving this out was a real defect (owner report 2026-08-23). The tilt + /// term divides the press offset by the key's own half-diagonal, so an + /// unclamped press point off the key drove that ratio past 1 without + /// limit - 500 DIP off a 40x30 key reached 20 - and tilt scales on it + /// directly. Deformation hit 2.5x the depth budget, and worse the smaller + /// the key, because the same drag divides by a smaller half-diagonal AND + /// projects onto a shorter reach. The budget is only a ceiling while + /// contact is on the cap. + public static Point Contact(Point press, Size size) => new( + Math.Clamp(press.X, 0, Math.Max(0, size.Width)), + Math.Clamp(press.Y, 0, Math.Max(0, size.Height))); + + /// How much of the press still reaches the cap: 1 while the + /// pointer is on the key, easing to 0 across + /// once it is not. Smooth at both ends, so grazing the edge does not make + /// the key flick. + /// + /// This also makes the motion honest. Releasing out here does not activate + /// the button, and a key that stayed fully depressed the whole time would + /// be promising an outcome that is not coming. + public static double Engagement(Point press, Size size) + { + if (size.Width <= 0 || size.Height <= 0) + return 0; + double outside = Distance(press, Contact(press, size)); + if (outside <= 0) + return 1; + if (outside >= DisengageBand) + return 0; + double t = 1 - outside / DisengageBand; + return t * t * (3 - 2 * t); + } + /// /// Displacement at one point of the cap, in DIP, positive INTO the key. /// A negative result is a genuine lift: the far side of a lever rises. /// - /// Where the finger is, in DIP from the key's top left. + /// Where the pointer is, in DIP from the key's top + /// left. May be outside the key: a press can be dragged anywhere while the + /// button holds capture, and the contact and disengage terms above handle + /// it. /// The point being displaced. /// The key's size in DIP. /// Press amount, 0 at rest, 1 at full press. Values @@ -69,6 +117,12 @@ public static double Displacement(Point press, Point sample, Size size, double a if (amount <= 0 || size.Width <= 0 || size.Height <= 0) return 0; + // the load that still reaches the cap, and where it bears + amount *= Engagement(press, size); + if (amount <= 0) + return 0; + press = Contact(press, size); + var center = new Point(size.Width / 2, size.Height / 2); double halfDiag = Distance(center, new Point(0, 0)); if (halfDiag <= 0) diff --git a/tests/CUETools.Linux.Tests/SoftBodyModelTests.cs b/tests/CUETools.Linux.Tests/SoftBodyModelTests.cs index 55ad3f8..ca41f7f 100644 --- a/tests/CUETools.Linux.Tests/SoftBodyModelTests.cs +++ b/tests/CUETools.Linux.Tests/SoftBodyModelTests.cs @@ -159,4 +159,126 @@ public void ARestingKeyIsPerfectlyFlat() for (double y = 0; y <= Key.Height; y += 4) Assert.Equal(0, Z(Corner, new Point(x, y), 0), 9); } + + // --- dragging off the key (owner report 2026-08-23) --------------------- + // Reported as "the shorter the button, the weirder it looks" for the same + // drag distance. The budget sweep above only ever pressed points INSIDE the + // key, so it could not see any of this. + + private static readonly Size[] Shapes = + { + new(360, 34), new(200, 40), new(120, 32), + new(90, 30), new(64, 30), new(40, 30), new(8, 8), + }; + + [Fact] + public void S15_010_APressDraggedOffTheKeyStaysInsideTheDepthBudget() + { + // the same invariant as S15_003, for the press points a real drag + // actually reaches. A pointer 500 DIP away is an ordinary drag. + foreach (Size size in Shapes) + foreach (double d in new double[] { 12, 60, 200, 500, 2000 }) + foreach (var dir in new[] { (1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.7, 0.7), (-0.7, -0.7) }) + { + var press = new Point( + size.Width / 2 + dir.Item1 * d, + size.Height / 2 + dir.Item2 * d); + for (double sx = 0; sx <= size.Width; sx += Math.Max(1, size.Width / 8)) + for (double sy = 0; sy <= size.Height; sy += Math.Max(1, size.Height / 4)) + { + double z = SoftBodyModel.Displacement(press, new Point(sx, sy), size, 1.0); + Assert.True(Math.Abs(z) <= SoftBodyModel.DepthBudget + 1e-9, + $"{size} dragged {d} to ({press.X:0.#},{press.Y:0.#}), sample ({sx},{sy}) = {z:0.000}"); + } + } + } + + [Fact] + public void S15_010_TheSameDragDoesNotDeformASmallKeyMoreThanALargeOne() + { + // the owner's actual complaint: identical gesture, wildly different + // result depending on key width. A drag off the key is a drag off the + // key; the key's own size must not amplify it. + double Worst(Size size) + { + var press = new Point(size.Width / 2, size.Height / 2 + 500); + double worst = 0; + for (double sx = 0; sx <= size.Width; sx += Math.Max(1, size.Width / 8)) + for (double sy = 0; sy <= size.Height; sy += Math.Max(1, size.Height / 4)) + worst = Math.Max(worst, + Math.Abs(SoftBodyModel.Displacement(press, new Point(sx, sy), size, 1.0))); + return worst; + } + + double large = Worst(new Size(360, 34)); + foreach (Size small in new[] { new Size(120, 32), new Size(90, 30), new Size(40, 30) }) + { + double got = Worst(small); + Assert.True(got <= large + 0.05, + $"{small} deformed {got:0.000} on the same drag that moved a 360x34 key {large:0.000}"); + } + } + + [Fact] + public void S15_011_ThePointerBearsOnTheKeyEvenWhenItHasLeftIt() + { + // a finger cannot press a point that is not on the key: contact stays + // at the nearest point on the surface, so sliding straight down past + // the bottom edge keeps rocking the key the same way, not further + var size = new Size(120, 32); + var sample = new Point(60, 30); + double atEdge = SoftBodyModel.Displacement(new Point(60, 32), sample, size, 1.0); + double wayPast = SoftBodyModel.Displacement(new Point(60, 900), sample, size, 1.0); + Assert.True(Math.Abs(wayPast) <= Math.Abs(atEdge) + 1e-9, + $"dragging past the edge deepened the press: {wayPast:0.000} vs {atEdge:0.000} at the edge"); + } + + [Fact] + public void S15_012_TheKeyRisesAsThePointerSlidesOffIt() + { + // real rubber: slide your finger off a key you are holding and the load + // transfers off, so it pops back up. That also tells the truth about + // the outcome, because releasing out here does not activate it. + var size = new Size(120, 32); + var sample = new Point(60, 16); + double onKey = Math.Abs(SoftBodyModel.Displacement(new Point(60, 16), sample, size, 1.0)); + double justOff = Math.Abs(SoftBodyModel.Displacement(new Point(60, 40), sample, size, 1.0)); + double wellOff = Math.Abs(SoftBodyModel.Displacement(new Point(60, 500), sample, size, 1.0)); + + Assert.True(justOff < onKey, $"key should relax as the pointer leaves: {justOff:0.000} vs {onKey:0.000}"); + Assert.Equal(0, wellOff, 9); + } + + [Fact] + public void S15_012_DraggingBackOntoTheKeyPressesItAgain() + { + // the disengage is not a latch: slide back on and it presses again, + // which is what the pointer semantics already promise on release + var size = new Size(120, 32); + var sample = new Point(60, 16); + double there = SoftBodyModel.Displacement(new Point(60, 16), sample, size, 1.0); + double gone = SoftBodyModel.Displacement(new Point(60, 500), sample, size, 1.0); + double back = SoftBodyModel.Displacement(new Point(60, 16), sample, size, 1.0); + Assert.Equal(0, gone, 9); + Assert.Equal(there, back, 9); + Assert.True(there > 0); + } + + [Fact] + public void S15_012_DisengagingIsSmoothSoTheKeyDoesNotSnapAtTheEdge() + { + // a hard cut at the boundary makes the key flick as the pointer grazes + // the edge; the load has to hand over across a band + var size = new Size(120, 32); + var sample = new Point(60, 16); + double prev = Math.Abs(SoftBodyModel.Displacement(new Point(60, 16), sample, size, 1.0)); + for (double y = 17; y <= 60; y += 1) + { + double z = Math.Abs(SoftBodyModel.Displacement(new Point(60, y), sample, size, 1.0)); + Assert.True(z <= prev + 1e-9, $"displacement rose while sliding away, at y={y}"); + Assert.True(prev - z < SoftBodyModel.DepthBudget * 0.25, + $"displacement dropped {prev - z:0.000} in one DIP at y={y}: that is a snap, not a slide"); + prev = z; + } + } } From 46ffc2b284eb164f0997817c391c1d49a93f3d72 Mon Sep 17 00:00:00 2001 From: LynxTWO Date: Sun, 23 Aug 2026 15:36:30 -0400 Subject: [PATCH 2/4] Light a dead key's legend at standby instead of blanking it The off look was right and the letters could not be read. Measured, they sat at 1.2:1 on the dark key face and 1.5:1 on the light one, against 14:1 and 16:1 for a live key. That is not dim, it is invisible: the legend had drifted to the brush meant for hairlines. On a real console the legend is a translucent window in an opaque cap with a bulb behind it, and turning the key off turns that bulb down to standby rather than out, so the letters keep a faint light of their own and you can still find the key in the dark. That is what this does: new KeyStandby and KeyStandbyGlow tokens per theme, plus a zero-offset drop shadow in the lamp colour, which is what makes it read as light coming through the letters rather than grey ink sitting on them. Bounded on both sides and pinned by test, because either failure is easy to reach: at least 3.0:1 so it reads, and no more than two fifths of the live key's ratio so it still looks unpowered, across both the resting and pressed faces in both themes. The pressed face binds on the light bench, since a key disabled mid-press takes the dead release while its face is still dark. A second test pins the hue - a neutral grey would clear the contrast bounds and lose the entire point. WCAG exempts disabled controls, which is why none of this was ever measured. The exemption says an inactive control need not meet AA. It does not say it may be unreadable. --- .../Theme/AnalogControls.axaml | 16 ++++++- src/CUETools.Linux.App/Theme/Palette.axaml | 17 +++++++ .../ContrastContractTests.cs | 46 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/CUETools.Linux.App/Theme/AnalogControls.axaml b/src/CUETools.Linux.App/Theme/AnalogControls.axaml index 96d4dd1..2545241 100644 --- a/src/CUETools.Linux.App/Theme/AnalogControls.axaml +++ b/src/CUETools.Linux.App/Theme/AnalogControls.axaml @@ -128,12 +128,24 @@ + diff --git a/src/CUETools.Linux.App/Theme/Palette.axaml b/src/CUETools.Linux.App/Theme/Palette.axaml index 8c191c3..e1424ce 100644 --- a/src/CUETools.Linux.App/Theme/Palette.axaml +++ b/src/CUETools.Linux.App/Theme/Palette.axaml @@ -24,6 +24,13 @@ #34CFC0 + + + #34CFC0 #2E000000 #5A000000 #24FFFFFF @@ -79,6 +86,16 @@ #087067 + + + #087067 #1A000000 diff --git a/tests/CUETools.Linux.Tests/ContrastContractTests.cs b/tests/CUETools.Linux.Tests/ContrastContractTests.cs index f29da51..7780c96 100644 --- a/tests/CUETools.Linux.Tests/ContrastContractTests.cs +++ b/tests/CUETools.Linux.Tests/ContrastContractTests.cs @@ -113,6 +113,52 @@ public void TheAccentKeysLabelReadsAtWcagAaOverBothGradientStops() } } + [AvaloniaFact] + public void ADeadKeysLegendStaysReadableWithoutLookingLive() + { + // WCAG exempts disabled controls, so nothing here was ever measured and + // the legend had drifted to the structural Line brush: 1.2:1 on the dark + // key face and 1.5:1 on the light one, which the owner reported as simply + // unreadable (2026-08-23). A dead key still has to say what it is. + // + // Both bounds matter. Too dark and the label cannot be read; too bright + // and the key stops looking unpowered, which is the state it exists to + // communicate. Standby sits between them: legible, and unmistakably not + // the live key sitting next to it. + foreach (ThemeVariant variant in new[] { ThemeVariant.Dark, ThemeVariant.Light }) + foreach (string surface in KeySurfaces) + foreach (Color face in SurfaceColors(surface, variant)) + { + double standby = Ratio(Resolve("KeyStandby", variant), face); + double live = Ratio(Resolve("Ink", variant), face); + + Assert.True(standby >= 3.0, + $"{variant} KeyStandby on {surface} stop #{face}: {standby:0.00}:1 is too dim to read"); + Assert.True(standby <= live / 2.5, + $"{variant} KeyStandby on {surface} stop #{face}: {standby:0.00}:1 against a live " + + $"{live:0.00}:1 is bright enough to read as powered"); + } + } + + [AvaloniaFact] + public void TheStandbyLegendIsLitInTheLampColourNotJustGreyedOut() + { + // the point of standby is a bulb turned down, not ink faded out, so the + // legend has to keep the lamp's hue. A grey here would still pass the + // contrast bounds above while losing the whole effect. + foreach (ThemeVariant variant in new[] { ThemeVariant.Dark, ThemeVariant.Light }) + { + Color standby = Resolve("KeyStandby", variant); + int max = Math.Max(standby.R, Math.Max(standby.G, standby.B)); + int min = Math.Min(standby.R, Math.Min(standby.G, standby.B)); + Assert.True(max - min >= 20, + $"{variant} KeyStandby #{standby} is within {max - min} of neutral grey: " + + "the standby legend must carry the lamp colour"); + Assert.True(standby.G >= standby.R && standby.B >= standby.R, + $"{variant} KeyStandby #{standby} is not on the lamp's teal side"); + } + } + [AvaloniaFact] public void NoViewUsesADecorativeConstantAsTextForeground() { From fc1e05839bb8267c07ca068bb80dbfd3459236d3 Mon Sep 17 00:00:00 2001 From: LynxTWO Date: Sun, 23 Aug 2026 15:36:30 -0400 Subject: [PATCH 3/4] Record the gate verdict and the two fixes behind it D-085 is the one that matters: the owner's gate question on the flag-gated build was whether the shaded dip reads as below the console surface or whether the outline has to move, and it reads as below. So the mesh renderer, the custom draw operation, the Skia-lane test project and the supersampled output path are all unfunded. The projective homography over a pinned rim is the shipping technique. D-086 and D-087 record the drag defect and the dead legend, including the numbers, so neither gets tuned back by someone who did not measure them. --- docs/DECISION-LOG.md | 78 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/docs/DECISION-LOG.md b/docs/DECISION-LOG.md index f962340..287d4fe 100644 --- a/docs/DECISION-LOG.md +++ b/docs/DECISION-LOG.md @@ -1368,3 +1368,81 @@ BECAUSE: All three were measured during the SLICE-015 research. Each is invisible to the compiler, to the XAML loader, and to the suite. REVISIT WHEN: Never as a "cleanup". Any change here needs its own measurement. + +DECISION: D-085 The gate passed, so the mesh renderer is not funded +STATUS: Confirmed +CHOICE: SLICE-015's owner gate asked one question of the running +build: does the shaded dip read as BELOW the console surface, or does +the outline have to move? Owner verdict 2026-08-23, on the flag-gated +build: "the shaded dip does read as below the surface". The cheap path +stands. +Everything that was funded only by the other answer is therefore not +being built: the DrawVertices mesh renderer, the custom draw operation, +the Skia-lane test project, and the supersampled output path costed in +D-083. The projective homography over a pinned rim, with a housing +recess behind the receding face, is the shipping technique. +BECAUSE: The gate existed precisely so this would be decided by +looking rather than by argument. A silhouette that cannot move was the +cheap path's one real compromise, and it turns out not to be one at +this scale. +REVISIT WHEN: A control gets large enough that a pinned rim reads as a +sticker rather than a cap. The rim is pinned in DIP, so the failure +mode is a big key, not a small one. + +DECISION: D-086 A held press that leaves the key hands its load back +STATUS: Confirmed +CHOICE: The deformation model gained two physical terms: contact is +clamped to the key's own rectangle, and the load eases off across a +14 DIP band once the pointer is outside it. +Owner report 2026-08-23: dragging a few hundred pixels off a key while +holding it looked fine on long keys and progressively wrong as the key +got shorter. Measured, that is a real defect and not a matter of taste. +The tilt term divides the press offset by the key's OWN half-diagonal, +so an unclamped press point off the key drives that ratio past 1 +without limit - 500 DIP off a 40x30 key reaches 20 - and tilt scales +on it directly. Worst displacement measured 14.79 DIP against a 6.0 +budget, 2.5 budgets through the console floor, and the exaggeration +grows as the key shrinks because the same drag divides by a smaller +half-diagonal AND projects onto a shorter reach. A long key is not +exempt, only slower: a 360x34 key breaks the budget too, at a longer +drag. +The fix is physical on both counts. A finger cannot load a point that +is not on the cap, so contact stays at the nearest point on the +surface. And sliding a finger off a key you are holding transfers the +pressure off it, so the key rises. The second term also makes the +motion honest, because releasing out there does not activate the +button, and a key that stayed fully depressed the whole time would be +promising an outcome that is not coming. Dragging back on presses it +again; the disengage is not a latch. +BECAUSE: The existing budget invariant (S15_003) swept press points +only INSIDE the key, so it could not see any of this. The budget is +only a ceiling while contact is on the cap. +REVISIT WHEN: The band is 14 DIP by eye, not by measurement. If a key +ever flicks as the pointer grazes its edge, that number is the one to +look at, not the smoothstep around it. + +DECISION: D-087 A dead key is unpowered, not blanked +STATUS: Confirmed +CHOICE: A disabled key's legend is lit at standby current instead of +being painted in the structural Line brush. New palette tokens +KeyStandby and KeyStandbyGlow, plus a zero-offset drop shadow in the +lamp colour on the content presenter, which is what makes it read as +light coming THROUGH translucent letters rather than grey ink sitting +on them. Same recipe as the lit rail glyphs. +Owner report 2026-08-23: the off look is right, but the text cannot be +read. Measured, the legend was at 1.2:1 on the dark key face and 1.5:1 +on the light one. That is not dim, it is invisible. A live key is 14:1 +and 16:1. +Standby is bounded on BOTH sides and pinned by test: at least 3.0:1 so +it can be read, and no more than two fifths of the live key's ratio so +it still reads as unpowered, across the resting AND pressed faces in +both themes. The pressed face is the binding case on the light bench, +because a key disabled mid-press takes the dead release while its face +is still darkened. A second test pins the hue, because a neutral grey +would pass the contrast bounds while losing the whole effect. +BECAUSE: WCAG 1.4.3 exempts disabled controls, so nothing here was +ever measured and the legend had quietly drifted to a brush meant for +hairlines. The exemption says an inactive control need not meet AA. It +does not say it may be unreadable. +REVISIT WHEN: A theme is added. Both bounds are relative to that +theme's own live key, so the numbers do not transfer. From 993a9847245214c2103ae5ca3a0607c80f5106af Mon Sep 17 00:00:00 2001 From: LynxTWO Date: Sun, 23 Aug 2026 16:42:12 -0400 Subject: [PATCH 4/4] Replace the dropdowns with banks and machined windows Every selector in the app now sits in one of two controls instead of a stock combo. Eleven of the fifteen were never lists: a dropdown hides its options behind a click, which is right for a drive list and wrong for three fixed choices that fit on one line. Neither is a new control class. The bank is a ListBox, which brings arrow-key selection, a per-key accessible name and selected state, and the same ItemsSource/SelectedItem/SelectedIndex the combos were already bound to, so most call sites change one element name and keep their bindings. The window is a ComboBox template restyle, so opening, keyboard handling and the accessible role are untouched. Each bank key is the machined cap from SLICE-014 with the lamp pip from the switches. Sixteen sites converted, six to banks and ten to windows. The first render clipped Salvage off the quality bank, which is worth recording because the design doc had cheerfully claimed the Rip page had room at 1200. It does not: the rail is 300, and the measurement that mattered was never taken. The label moves above the bank there, a compact variant tightens the keys, and the items panel wraps so a bank that truly cannot fit becomes a two-row keypad rather than losing a position. Two things also turned out to be real bugs - the key template hardcoded its padding instead of binding it, so the compact variant did nothing at all. The scale matrix could not catch any of this. It asserts the page does not overflow its viewport, and a bank inside a fixed-width column overflows the column while the page stays exactly as wide as before. The clipped render was green on every other assertion in the suite, so there are now two layout tests that look inside the bank. --- docs/SLICE-016-selectors.md | 75 ++++- .../Controls/SelectorPosition.cs | 33 ++ .../Theme/AnalogControls.axaml | 300 +++++++++++++++++- .../Views/AdvancedView.axaml | 6 +- src/CUETools.Linux.App/Views/DriveView.axaml | 2 +- .../Views/EncoderSettingsWindow.axaml | 6 +- src/CUETools.Linux.App/Views/NamingView.axaml | 2 +- src/CUETools.Linux.App/Views/QueueView.axaml | 2 +- src/CUETools.Linux.App/Views/RipView.axaml | 46 ++- .../Views/SettingsView.axaml | 8 +- .../SelectorLayoutTests.cs | 132 ++++++++ 11 files changed, 558 insertions(+), 54 deletions(-) create mode 100644 src/CUETools.Linux.App/Controls/SelectorPosition.cs create mode 100644 tests/CUETools.Linux.Tests/SelectorLayoutTests.cs diff --git a/docs/SLICE-016-selectors.md b/docs/SLICE-016-selectors.md index ee9cbbb..62d8162 100644 --- a/docs/SLICE-016-selectors.md +++ b/docs/SLICE-016-selectors.md @@ -1,6 +1,7 @@ # CUETools Linux Slice Brief: SLICE-016 analog selectors -Version: 0.1. Date: 2026-08-23. Status: Awaiting owner decision. +Version: 0.2. Date: 2026-08-23. Status: Approved for build (owner stamp +2026-08-23, "I agree with your mockups of the dropdowns"), and built. Companion documents: ARCHITECTURE.md, DECISION-LOG.md (D-080 the analog control scope, D-082 where the shared skills live), SLICE-014-analog-controls.md, SLICE-015-soft-body-keys.md. @@ -79,14 +80,33 @@ control and it is the owner's call. Queue action is the other judgement call. It sits inside a table row, where a bank fights the column sizing. -## 4. Width +## 4. Width, and where v0.1 got it wrong -The four-key quality bank measures about 300 pixels against roughly -140 for the dropdown it replaces. The Rip page has that room at its -1200 pixel default and the Advanced rows have it at 440, so nothing -here forces a change to the reflow breakpoints recorded for the Rip -page. This must be re-measured, not assumed, if a bank ever lands on -the Rip page's right rail. +v0.1 said the Rip page "has room at its 1200 pixel default". That was +the page width, not the width the control actually gets, and the first +render of the build cut "Salvage" off the quality bank. The Rip page's +right rail is **300 DIP**, about 274 of it usable, and a four-key bank +beside its own label does not fit there at any padding worth having. +The measurement that mattered was never taken. + +Three things came out of that, and all three are in the build: + +- The label goes **above** the bank in the rail rather than beside it, + which returns roughly 58 DIP. +- A `compact` bank tightens the keys to 8 DIP of horizontal padding at + 11.5 point. All four quality positions then fit on one line in 274. +- The bank's items panel is a **WrapPanel**, so when a bank genuinely + cannot fit, it becomes a two-row keypad instead of losing a position. + Console hardware solves the same problem the same way. + +The Advanced rows are 440 DIP and take a bank beside its label with +room to spare, which is what the mockups showed. + +A layout test now pins this. The scale matrix could not see it: it +asserts the PAGE does not overflow its viewport, and a bank inside a +fixed-width column overflows the column while the page stays exactly +as wide as before. The first clipped render was green on every other +assertion in the suite. ## 5. Out of scope, on purpose @@ -110,11 +130,34 @@ Advanced rows in the proposed and current form for direct comparison. Published for owner review; the harness patch was reverted after each capture and the working tree verified clean. -## 7. Acceptance criteria - -Not written. This document exists to carry the design past a context -boundary and to be decided against, not to be built from yet. When -approved, the criteria come from SLICE-014's, plus one addition: a -bank must be operable by keyboard with arrow keys moving the held -position, and each key must expose its own accessible name and -selected state. +## 7. What the build did + +Both controls are styles rather than new control classes, which is why +this landed as one pass. + +- The **bank** is `ListBox` with `Classes="bank"`. Arrow-key selection, + the accessible name and selected state per key, and the + `ItemsSource`/`SelectedItem`/`SelectedIndex` trio the old + ComboBoxes were already bound to all come with `ListBox`, so a call + site changes one element name and keeps its bindings. +- The **window** is a `ComboBox` template restyle with + `Classes="window"`, so opening, keyboard handling and the accessible + role are untouched. `Classes="window counted"` adds the position + readout, which stays opt-in per the v0.1 ruling. + +Sixteen call sites converted: six to banks (quality, layout on Rip and +on Settings, metadata search, album art search, network) and ten to +windows. + +## 8. Verification + +- 189 tests green, including two new layout tests: no bank position is + ever cut off at any of the five supported widths in either theme, + and every position is visible, non-empty, and reachable. +- Rendered through the Skia harness at 1200 DIP in both themes: the + Rip page rail and the Advanced page rows. +- The existing scale matrix still passes unchanged, which matters + because banks are wider than the dropdowns they replaced. + +Still owed: the owner's eyes on the running build, and a decision on +the two open calls in section 3 (CTDB sharing wording, Queue action). diff --git a/src/CUETools.Linux.App/Controls/SelectorPosition.cs b/src/CUETools.Linux.App/Controls/SelectorPosition.cs new file mode 100644 index 0000000..3ca8b72 --- /dev/null +++ b/src/CUETools.Linux.App/Controls/SelectorPosition.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using Avalonia.Data.Converters; + +namespace CUETools.Linux.App.Controls; + +/// +/// The position readout under a machined selector window's value: "3 / 7". +/// A tape counter, and it earns its space the same way one does - by saying +/// how much else is in there without opening anything. On a drive list that +/// is a real signal; the readout is opt-in per site (the "counted" class) +/// rather than global, because on a fixed three-item list it is noise. +/// +/// Silent by design when it has nothing true to say: no selection, an empty +/// list, or a list of one all render as empty rather than as "0 / 0" or a +/// counter that never moves. +/// +public sealed class SelectorPositionConverter : IMultiValueConverter +{ + public static readonly SelectorPositionConverter Instance = new(); + + public object Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Count < 2 || + values[0] is not int index || + values[1] is not int count || + count <= 1 || + index < 0 || + index >= count) + return string.Empty; + + return $"{index + 1} / {count}"; + } +} diff --git a/src/CUETools.Linux.App/Theme/AnalogControls.axaml b/src/CUETools.Linux.App/Theme/AnalogControls.axaml index 2545241..2dc3a98 100644 --- a/src/CUETools.Linux.App/Theme/AnalogControls.axaml +++ b/src/CUETools.Linux.App/Theme/AnalogControls.axaml @@ -1,5 +1,6 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:CUETools.Linux.App.Controls"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CUETools.Linux.App/Views/AdvancedView.axaml b/src/CUETools.Linux.App/Views/AdvancedView.axaml index 598aa5b..fa8bd92 100644 --- a/src/CUETools.Linux.App/Views/AdvancedView.axaml +++ b/src/CUETools.Linux.App/Views/AdvancedView.axaml @@ -65,7 +65,7 @@ - + @@ -87,13 +87,13 @@ - + - + diff --git a/src/CUETools.Linux.App/Views/DriveView.axaml b/src/CUETools.Linux.App/Views/DriveView.axaml index f4bbd9f..87ca9df 100644 --- a/src/CUETools.Linux.App/Views/DriveView.axaml +++ b/src/CUETools.Linux.App/Views/DriveView.axaml @@ -75,7 +75,7 @@ - diff --git a/src/CUETools.Linux.App/Views/EncoderSettingsWindow.axaml b/src/CUETools.Linux.App/Views/EncoderSettingsWindow.axaml index d55a58e..2d09dad 100644 --- a/src/CUETools.Linux.App/Views/EncoderSettingsWindow.axaml +++ b/src/CUETools.Linux.App/Views/EncoderSettingsWindow.axaml @@ -44,7 +44,7 @@ BorderBrush="{DynamicResource Line}" BorderThickness="1" CornerRadius="9" Padding="12,10"> - @@ -63,7 +63,7 @@ BorderBrush="{DynamicResource Line}" BorderThickness="1" CornerRadius="9" Padding="12,10"> - @@ -93,7 +93,7 @@ - diff --git a/src/CUETools.Linux.App/Views/NamingView.axaml b/src/CUETools.Linux.App/Views/NamingView.axaml index fdff76e..72ea959 100644 --- a/src/CUETools.Linux.App/Views/NamingView.axaml +++ b/src/CUETools.Linux.App/Views/NamingView.axaml @@ -37,7 +37,7 @@ - diff --git a/src/CUETools.Linux.App/Views/QueueView.axaml b/src/CUETools.Linux.App/Views/QueueView.axaml index 293a2aa..0c39eec 100644 --- a/src/CUETools.Linux.App/Views/QueueView.axaml +++ b/src/CUETools.Linux.App/Views/QueueView.axaml @@ -15,7 +15,7 @@ -