Put the adjustment groups in the rail where a finger is driving

Reported from the tablet: the tool rail is very useful there, and the same
interface under a mouse and keyboard is not. That is `ui-navigation.md` D-N2's
central assumption failing in use, and the interesting part is which half of
it failed.

D-N2 was right that platform is the wrong axis and width is the wrong axis: a
tablet in landscape wants what a desktop wants, and a desktop window dragged
narrow wants what a small screen wants. `apply_layout_class` still decides the
layout class from the window and nothing here changes that. What D-N2 got
wrong is the sentence "touch changes hit regions, not layout" — it identified
input as the real difference between the targets and then assumed that
difference could never reach the layout.

Two controls answer one question — which group of adjustments am I looking at
— and neither is better in general. A horizontal strip above the column is one
gesture to a target the eye has already found, and it pans when the operation
set is rich, so a group can sit off the end with nothing saying so: a pointer
user tolerates that, a finger user never discovers it. The same list down the
rail is every entry visible at once, each finger-sized, on the edge of the
screen the hand is already holding, and it costs no width because the rail is
already there.

So `ToolRail` grows a second section, and `GroupStrip` stands down when it
does. The two are never both on screen, which is why they can share
`adjust-tab-picked`: Rust is not told which was pressed and has no reason to
want to. Mode and group stay independent axes as N1 requires — one entry lit
in each section, and choosing a group while a tool is held still filters
without putting the tool down.

They stay drawn differently, which N1 also required. The tools fill with
`active-dim` and invert their ink; the groups take a bar down the leading
edge — the strip's underline turned ninety degrees — so a lit entry says which
kind of state it is without the reader having to remember which section it was
in. The rule between the sections is the second signal.

The rail scrolls now. Its own note argued against a Flickable because "this
list is four entries written in this file"; with the groups in it the list
comes from the operation set, which is exactly the "something the user's data
decides" that note excluded this control from.

The axis is input, and it is a preference because the automatic answer is a
guess that cannot be made reliable. Neither platform can be asked what the
user is holding: an Android tablet in a keyboard case is being driven like a
desktop, and a touchscreen laptop is whichever its owner says.
`dr_plat::is_touch_first` reports the usual case per platform, and
`GroupNavigation` lets it be overridden. Settings names what Automatic
resolves to on this device rather than leaving it to be found by pressing.

D-N6 records the reversal beside the decision it reverses, including the half
that still stands and the question it opens: whether Local is a mode at all,
or a scope that would collapse the two sections into one list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 18:14:03 +02:00
co-authored by Claude Opus 5
parent 474dcf0bf6
commit 6a97fdf6f9
10 changed files with 573 additions and 22 deletions
+2 -2
View File
@@ -19,8 +19,8 @@ pub use place::{Place, PlaceScope, Screen, StoredFilter};
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
pub use settings::{
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
ExportTarget, ImportSettings, LibrarySettings, OutputSharpening, ScreenSize, Settings,
SizingMode,
ExportTarget, GroupNavigation, ImportSettings, LibrarySettings, OutputSharpening, ScreenSize,
Settings, SizingMode,
};
pub use time::{
civil_from_unix, civil_from_unix_at, format_date, parse_date, unix_from_civil, Civil,
+90
View File
@@ -317,6 +317,54 @@ pub struct DevelopSettings {
/// derives the set from the old boolean exactly once, and every later read
/// finds a real answer here.
pub copy_attributes: Option<Vec<String>>,
/// TRACES: FR-UI-1 | FR-UI-7
/// Where the adjustment groups are chosen from.
///
/// `Auto` — the default — decides from how the interface is being driven
/// rather than from which binary is running. `ui-navigation.md` D-N2 ruled
/// out a platform split and was right about the reason: a tablet in
/// landscape wants what a desktop wants, so `cfg(target_os)` gives one
/// physical situation two answers. What it got wrong was the conclusion
/// that nothing therefore differs — it assumed touch changes hit regions
/// and not layout, and a rail full of finger-sized targets against a
/// horizontal strip that pans is a counter-example.
///
/// So the axis is input, which is the one D-N2 itself identified as the
/// real difference between the targets, and it is overridable because the
/// automatic answer cannot be right for everyone: a tablet with a keyboard
/// case is being driven like a desktop, and a touchscreen laptop is
/// whichever its owner says.
pub group_navigation: GroupNavigation,
}
/// TRACES: FR-UI-1
/// Where the develop view's adjustment groups are chosen from.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupNavigation {
/// Follow the input modality: the rail under a finger, tabs under a mouse.
#[default]
Auto,
/// Always down the tool rail.
Rail,
/// Always the strip above the develop column.
Tabs,
}
impl GroupNavigation {
/// Whether the groups belong in the rail, given how this device is driven.
///
/// Takes the modality rather than reading it, because deciding it needs a
/// `cfg` this crate has no business carrying: `dr-types` is the shared
/// vocabulary and sits below everything that knows what a platform is.
pub fn groups_in_rail(self, touch: bool) -> bool {
match self {
Self::Auto => touch,
Self::Rail => true,
Self::Tabs => false,
}
}
}
// ---------------------------------------------------------------------------
@@ -1114,6 +1162,48 @@ pub mod budget {
#[cfg(test)]
mod tests {
/// TRACES: FR-UI-1
/// The automatic answer follows the input, and the overrides do not.
#[test]
fn group_navigation_follows_input_only_when_asked_to() {
// Auto is the only variant that consults the modality. The other two
// exist precisely because the guess is sometimes wrong — a tablet in a
// keyboard case, a touchscreen laptop — so a variant that quietly fell
// back to the platform would be no override at all.
assert!(GroupNavigation::Auto.groups_in_rail(true));
assert!(!GroupNavigation::Auto.groups_in_rail(false));
for touch in [true, false] {
assert!(
GroupNavigation::Rail.groups_in_rail(touch),
"an explicit choice of the rail must survive a {touch} device"
);
assert!(
!GroupNavigation::Tabs.groups_in_rail(touch),
"an explicit choice of tabs must survive a {touch} device"
);
}
}
/// A config written before this setting existed must read as `Auto`.
///
/// Not a formality: `#[serde(default)]` on the struct is what makes an
/// older file load at all, and the derived `Default` on the enum is what
/// decides which answer it lands on. Getting that wrong would move every
/// existing photographer's develop panel on upgrade, for a preference they
/// never expressed.
#[test]
fn a_config_without_the_setting_defaults_to_automatic() {
let older: Settings = serde_json::from_str("{\"develop\": {}}").expect("older config");
assert_eq!(older.develop.group_navigation, GroupNavigation::Auto);
// And it round-trips once written, so a device that has never been
// touched does not keep re-deciding.
let text = serde_json::to_string(&older).expect("serialise");
let back: Settings = serde_json::from_str(&text).expect("reload");
assert_eq!(back.develop.group_navigation, GroupNavigation::Auto);
}
use super::*;
#[test]