Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions docs/DECISION-LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
75 changes: 59 additions & 16 deletions docs/SLICE-016-selectors.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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).
33 changes: 33 additions & 0 deletions src/CUETools.Linux.App/Controls/SelectorPosition.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Globalization;
using Avalonia.Data.Converters;

namespace CUETools.Linux.App.Controls;

/// <summary>
/// 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.
/// </summary>
public sealed class SelectorPositionConverter : IMultiValueConverter
{
public static readonly SelectorPositionConverter Instance = new();

public object Convert(IList<object?> 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}";
}
}
56 changes: 55 additions & 1 deletion src/CUETools.Linux.App/Controls/SoftBodyModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,59 @@ public static class SoftBodyModel
/// read as a slab on a spring rather than a lever on a pivot.</summary>
private const double PlungerReach = 0.55;

/// <summary>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.</summary>
public const double DisengageBand = 14.0;

/// <summary>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.</summary>
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)));

/// <summary>How much of the press still reaches the cap: 1 while the
/// pointer is on the key, easing to 0 across <see cref="DisengageBand"/>
/// 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.</summary>
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);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="press">Where the finger is, in DIP from the key's top left.</param>
/// <param name="press">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.</param>
/// <param name="sample">The point being displaced.</param>
/// <param name="size">The key's size in DIP.</param>
/// <param name="amount">Press amount, 0 at rest, 1 at full press. Values
Expand All @@ -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)
Expand Down
Loading
Loading