Open the film list as a popup, so every stock can be scrolled to

The open stock list showed ten rows, None to Kodak Kodachrome 64, and
the other eighteen - all seven black-and-white stocks among them - could
not be reached. The data was whole; the list could not be scrolled.

Reproduced on the manual rig (Xvfb, xdotool, the automation hook):

- a drag on the list scrolled the develop column, never the list;
- a wheel run over the list scrolled the column past it, whenever the
  column had scrolled under that pointer in the last 800 ms - which is
  how the list is reached, by wheeling the column down to it. After a
  pause and a pointer move the wheel did reach the list;
- no key did anything.

The cause is Slint's routing, not the list. Since 2d878c2 the list was a
Flickable inside the develop column's Flickable, and Slint offers every
pointer event to the outermost Flickable first
(input_event_filter_before_children, i-slint-core 1.17.1 flickable.rs).
The column holds a press back (DelayForwarding) and intercepts the first
move past 8 px on the axis it can scroll, so the list never saw a drag.
For the wheel it intercepts while its own last wheel event is under
800 ms old and within 2 px, and always for a touchpad gesture that opens
with TouchPhase::Started - so on a touchpad the list could get no wheel
at all.

The list is now a PopupWindow under the Film row. A popup is its own
item tree: while it is open, events go to it and to nothing beneath it,
so the list scrolls by wheel, drag and flick however the panel is nested
and whatever the column did last. The alternative, standing the column
down while the pointer is over the list (the sliders' hover trick), fixes
the drag but not the wheel - `interactive: false` does not gate wheel
interception - so it would have left the bug for touchpad users.

The reason 2d878c2 bounded the list still holds: it is at most 320 px
and never lengthens the column, and now it covers the sliders instead of
pushing them down. The column cannot be scrolled while it is open, which
suits a one-click question; it closes on choosing, on Escape or Back, or
on a press outside it.

Keys, with the list open: Up and Down move along it from the chosen
stock and scroll it into view, Enter chooses, Escape or Back closes it
unchanged. A popup is its own focus tree, so the keys are taken when it
opens and Slint returns focus to the develop view's scope when it closes.
The Film row gains a button role, so a screen reader and the automation
hook can name it.
This commit is contained in:
2026-09-26 07:24:28 -04:00
parent 6640ce0ca8
commit 80938b0527
6 changed files with 217 additions and 79 deletions
File diff suppressed because one or more lines are too long
+13 -2
View File
@@ -5,7 +5,7 @@
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it. Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
74 gestures, in 7 places. 75 gestures, in 7 places.
## Develop ## Develop
@@ -17,7 +17,18 @@ Every entry here is extracted from the comment beside the code that implements i
Sampling a neutral is the first move of the tonal pass — every colour judgement afterwards is measured against where the grey was put — and guessing at two sliders until a wall stops looking green is the wrong way round. One click is one sample and one step to undo; the sliders stay, because a sampled neutral is where the decision starts rather than where it ends. Sampling a neutral is the first move of the tonal pass — every colour judgement afterwards is measured against where the grey was put — and guessing at two sliders until a wall stops looking green is the wrong way round. One click is one sample and one step to undo; the sliders stay, because a sampled neutral is where the decision starts rather than where it ends.
<sub>`ui/dr-ui/ui/adjust.slint:158`</sub> <sub>`ui/dr-ui/ui/adjust.slint:159`</sub>
### Choose a film stock from the keyboard
- **Touch** — Tap the Film row, flick the list, tap a stock
- **Pointer** — Click the Film row, then a stock; the wheel or a drag scrolls the list
- **Keyboard** — With the list open, `↑` and `↓` move along it, `Enter` chooses, and `Escape` or `Back` closes it unchanged
- **See it** — [in the manual](manual/README.md#film)
The list is longer than it is tall, so a way to walk it that cannot be lost to the column's scrolling is part of it being reachable at all. Focus goes back to the photograph's keys when it closes.
<sub>`ui/dr-ui/ui/adjust.slint:1465`</sub>
### Magnify the photograph by any amount ### Magnify the photograph by any amount
+3 -1
View File
@@ -233,7 +233,9 @@ opacity are in the panel. Heal blends; clone copies.
The `Film` chooser at the head of Adjust applies a spectral simulation of a The `Film` chooser at the head of Adjust applies a spectral simulation of a
named stock; below it, the print exposure and push controls a film has and a named stock; below it, the print exposure and push controls a film has and a
sensor does not. sensor does not. The list opens over the column and scrolls on its own — by
wheel, drag or flick, or with `Up`, `Down` and `Enter` — down to the
black-and-white stocks at its end.
![Choosing Velvia, then holding Before](media/film.gif) ![Choosing Velvia, then holding Before](media/film.gif)
+3 -1
View File
@@ -296,7 +296,9 @@ opacity are in the panel. Heal blends; clone copies.</p>
<h3 id="film">Film</h3> <h3 id="film">Film</h3>
<p>The <code>Film</code> chooser at the head of Adjust applies a spectral simulation of a <p>The <code>Film</code> chooser at the head of Adjust applies a spectral simulation of a
named stock; below it, the print exposure and push controls a film has and a named stock; below it, the print exposure and push controls a film has and a
sensor does not.</p> sensor does not. The list opens over the column and scrolls on its own — by
wheel, drag or flick, or with <code>Up</code>, <code>Down</code> and <code>Enter</code> — down to the
black-and-white stocks at its end.</p>
<figure><img loading="lazy" src="media/film.gif" alt="Choosing Velvia, then holding Before"><figcaption>Choosing Velvia, then holding Before</figcaption></figure> <figure><img loading="lazy" src="media/film.gif" alt="Choosing Velvia, then holding Before"><figcaption>Choosing Velvia, then holding Before</figcaption></figure>
<h3 id="history-snapshots-presets">History, snapshots, presets</h3> <h3 id="history-snapshots-presets">History, snapshots, presets</h3>
<p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code> <p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code>
+8
View File
@@ -32,6 +32,14 @@ pub const GESTURES: &[Gesture] = &[
keys: "", keys: "",
manual: "white-balance-from-the-photograph", manual: "white-balance-from-the-photograph",
}, },
Gesture {
title: "Choose a film stock from the keyboard",
section: "Develop",
touch: "Tap the Film row, flick the list, tap a stock",
pointer: "Click the Film row, then a stock; the wheel or a drag scrolls the list",
keys: "With the list open, ↑ and ↓ move along it, Enter chooses, and Escape or Back closes it unchanged",
manual: "film",
},
Gesture { Gesture {
title: "Magnify the photograph by any amount", title: "Magnify the photograph by any amount",
section: "Develop", section: "Develop",
+181 -66
View File
@@ -9,6 +9,7 @@
import { Theme } from "theme.slint"; import { Theme } from "theme.slint";
import { Develop } from "session.slint"; import { Develop } from "session.slint";
import { Keys } from "keys.slint";
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint"; import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
import { SliderTrack, ControlRow, CurveEditor, Segmented, ChoiceChip, ChipGrid, Check } from "controls.slint"; import { SliderTrack, ControlRow, CurveEditor, Segmented, ChoiceChip, ChipGrid, Check } from "controls.slint";
@@ -1232,16 +1233,9 @@ export component GroupStrip inherits Rectangle {
// The column is closed as a whole from the status strip instead, which is the // The column is closed as a whole from the status strip instead, which is the
// control that was actually wanted. // control that was actually wanted.
export component AdjustPanel inherits Rectangle { export component AdjustPanel inherits Rectangle {
/// Whether the stock list is open. Pure interface state: it changes no
/// parameter and the core never hears about it.
///
/// Private to the panel rather than in `Adjustments`, and deliberately: it
/// is a lid, and two drawings of this panel may honestly have their lids
/// in different positions.
private property <bool> film-expanded: false;
/// One stock. Shorter than a touch target on purpose — see the row below. /// One stock. Shorter than a touch target on purpose — see the row below.
private property <length> film-row-height: 32px; private property <length> film-row-height: 32px;
/// How much column the open list may take before it scrolls instead. /// How tall the open list may be before it scrolls instead.
private property <length> film-list-max: 320px; private property <length> film-list-max: 320px;
background: Theme.surface; background: Theme.surface;
@@ -1317,19 +1311,51 @@ export component AdjustPanel inherits Rectangle {
// thousand pixels of column standing between the photographer and // thousand pixels of column standing between the photographer and
// every slider below it. // every slider below it.
// //
// No PopupWindow, no scroller of its own: the develop column // **Opened as a popup over the column, not as rows inside it.**
// scrolls as one, so the list is simply taller content while it is //
// open, and there is no second gesture to lose a drag to. // It has been both of the other things, and each lost stocks:
//
// - Laid out at full height, two dozen stocks were the tallest
// thing in the column, and reaching Velvia scrolled every
// slider below it off the screen — the answer to a question
// asked once pushing aside the controls used constantly.
// - Bounded, with a Flickable of its own (2d878c2), it was a
// scroller inside the column's scroller, and Slint routes a
// gesture to the *outermost* Flickable first. A drag or a
// flick on the list was claimed by the column before the list
// saw the press, and a wheel was too while the column had
// scrolled in the last 800ms under a still pointer — which is
// exactly how a list is arrived at, wheeling the column down
// to it. The eighteen stocks past Kodachrome 64, every
// black-and-white one among them, could not be reached, and
// looked deleted.
//
// A PopupWindow is a separate tree: while it is open, input goes
// to it and to nothing under it, so the list scrolls by wheel,
// drag and flick however deeply the panel is nested and whatever
// the column did last. It keeps the reason the list was bounded —
// it never lengthens the column, so no slider moves — and, as it
// covers the column rather than pushing it, it does not move the
// sliders even while it is open.
//
// The column cannot be scrolled while it is open. That is the
// right trade for a question answered in one click: the popup is
// closed by choosing, by Escape or Back, or by pressing anywhere
// outside it.
summary := TouchArea { summary := TouchArea {
height: Theme.touch-target; height: Theme.touch-target;
mouse-cursor: pointer; mouse-cursor: pointer;
clicked => { root.film-expanded = !root.film-expanded; } clicked => { film-list.show(); }
accessible-role: button;
accessible-label: "Film";
accessible-value: Adjustments.film-stocks[Adjustments.film-selected];
accessible-action-default => { film-list.show(); }
Rectangle { Rectangle {
border-radius: Theme.radius; border-radius: Theme.radius;
border-width: 1px; border-width: 1px;
border-color: Theme.rule; border-color: Theme.rule;
background: summary.pressed ? Theme.pressed background: summary.pressed || film-list.is-open ? Theme.pressed
: (summary.has-hover ? Theme.hover : transparent); : (summary.has-hover ? Theme.hover : transparent);
HorizontalLayout { HorizontalLayout {
@@ -1352,73 +1378,162 @@ export component AdjustPanel inherits Rectangle {
vertical-alignment: center; vertical-alignment: center;
} }
Label { Label {
text: root.film-expanded ? "\u{25be}" : "\u{25b8}"; text: film-list.is-open ? "\u{25be}" : "\u{25b8}";
vertical-alignment: center; vertical-alignment: center;
} }
} }
} }
}
// **Its own scroller, bounded.** Two dozen stocks laid out at full // Directly under the summary and as wide as it. Slint keeps a
// height made the list the tallest thing in the column, so the // popup inside the window, so a summary near the bottom edge
// develop panel scrolled as one and reaching Velvia dragged every // opens its list upward over itself rather than off-screen.
// slider below it off the screen — the answer to a question that film-list := PopupWindow {
// is asked once pushing aside the controls used constantly. x: 0px;
// y: parent.height + Theme.gap-sm;
// The viewport is counted rather than measured, for the reason the width: parent.width;
// tool rail records: a viewport that asks a layout how tall it height: min(Adjustments.film-stocks.length * root.film-row-height, root.film-list-max);
// wants to be, while the layout takes its height from the viewport, close-policy: PopupClosePolicy.close-on-click-outside;
// is a cycle Slint settles by handing back the height it was given
// — and the content is then clipped in silence instead of
// scrolling. Every row here is `film-row-height` by construction,
// so multiplying is exact.
if root.film-expanded: Flickable {
height: min(Adjustments.film-stocks.length * root.film-row-height, root.film-list-max);
viewport-width: self.width;
viewport-height: Adjustments.film-stocks.length * root.film-row-height;
list := VerticalLayout {
height: parent.viewport-height;
spacing: 0px;
alignment: start;
for stock[i] in Adjustments.film-stocks: film-row := TouchArea {
// Shorter than a touch target, which is a deliberate
// exception and the only one here: a list row's neighbours
// are other rows, so growing the hit area the way a chip
// does would put each row's target over the one above it
// and hand taps to the wrong film.
height: 32px;
mouse-cursor: pointer;
clicked => {
Adjustments.film-picked(i);
// Closed on choosing. The answer is now in the summary
// row, and leaving two dozen entries open after the
// question has been answered is the behaviour this
// control was collapsed to avoid.
root.film-expanded = false;
}
Rectangle { Rectangle {
background: Theme.surface;
border-radius: Theme.radius; border-radius: Theme.radius;
background: i == Adjustments.film-selected ? Theme.active border-width: 1px;
: (film-row.pressed ? Theme.pressed border-color: Theme.rule;
: (film-row.has-hover ? Theme.hover : transparent)); clip: true;
HorizontalLayout { // The viewport is counted rather than measured, for
padding-left: Theme.gap; // the reason the tool rail records: a viewport that
padding-right: Theme.gap-sm; // asks a layout how tall it wants to be, while the
alignment: start; // layout takes its height from the viewport, is a cycle
Label { // Slint settles by handing back the height it was given
text: stock; // — and the content is then clipped in silence instead
vertical-alignment: center; // of scrolling. Every row is `film-row-height` by
overflow: elide; // construction, so multiplying is exact.
film-flick := Flickable {
width: parent.width;
height: parent.height;
viewport-width: self.width;
viewport-height: Adjustments.film-stocks.length * root.film-row-height;
VerticalLayout {
height: parent.viewport-height;
spacing: 0px;
alignment: start;
for stock[i] in Adjustments.film-stocks: film-row := TouchArea {
// Shorter than a touch target, which is a
// deliberate exception and the only one
// here: a list row's neighbours are other
// rows, so growing the hit area the way a
// chip does would put each row's target
// over the one above it and hand taps to
// the wrong film.
height: root.film-row-height;
mouse-cursor: pointer;
// Closed on choosing. The answer is now in
// the summary row, and leaving two dozen
// entries open after the question has been
// answered is what this control was
// collapsed to avoid.
clicked => {
Adjustments.film-picked(i);
film-list.close();
}
Rectangle {
border-radius: Theme.radius;
background: i == Adjustments.film-selected ? Theme.active
: (film-row.pressed ? Theme.pressed
: (film-row.has-hover || i == film-keys.cursor
? Theme.hover : transparent));
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap-sm;
alignment: start;
Label {
text: stock;
vertical-alignment: center;
overflow: elide;
}
}
}
}
}
}
// GESTURE: Choose a film stock from the keyboard
// where: Develop
// touch: Tap the Film row, flick the list, tap a stock
// pointer: Click the Film row, then a stock; the wheel
// or a drag scrolls the list
// keys: With the list open, `Up` and `Down` move
// along it, `Enter` chooses, and `Escape` or
// `Back` closes it unchanged
// why: The list is longer than it is tall, so a
// way to walk it that cannot be lost to the
// column's scrolling is part of it being
// reachable at all. Focus goes back to the
// photograph's keys when it closes.
// manual: film
//
// The keys, on a holder of no size inside the popup.
// A popup is its own focus tree, so this takes the keys
// when the list opens — its contents are built afresh
// each time — and Slint hands them back to the develop
// view's scope when it closes, however it closed.
film-keys := FocusScope {
width: 0px;
height: 0px;
/// The row the keys are on. Starts on the chosen
/// stock, so opening and pressing Enter changes
/// nothing.
property <int> cursor: Adjustments.film-selected;
init => {
self.focus();
self.reveal(self.cursor);
}
/// Scroll just far enough that row `i` is whole.
function reveal(i: int) {
if (i * root.film-row-height + film-flick.viewport-y < 0px) {
film-flick.viewport-y = -i * root.film-row-height;
} else if ((i + 1) * root.film-row-height + film-flick.viewport-y > film-flick.height) {
film-flick.viewport-y = film-flick.height - (i + 1) * root.film-row-height;
}
}
function step(by: int) {
self.cursor = clamp(self.cursor + by, 0, Adjustments.film-stocks.length - 1);
self.reveal(self.cursor);
}
// KEYMAP: Develop
key-pressed(event) => {
if (Keys.chord(event) == "Down") {
self.step(1);
return accept;
}
if (Keys.chord(event) == "Up") {
self.step(-1);
return accept;
}
if (Keys.chord(event) == "Enter") {
Adjustments.film-picked(self.cursor);
film-list.close();
return accept;
}
if (Keys.chord(event) == "Escape" || Keys.chord(event) == "Back") {
film-list.close();
return accept;
}
return reject;
} }
} }
} }
} }
} }
}
// Only for a negative. A reversal stock has no paper — it is the // Only for a negative. A reversal stock has no paper — it is the
// photograph as it comes — so offering the choice would be // photograph as it comes — so offering the choice would be