Ask the window how tall it is, and say when the column belongs below

D-N7 puts the develop column under the photograph on a tall window, and the
axis it turns on is aspect rather than width: a 960-wide portrait tablet is
expanded by width and wants the dock, a 1500-wide landscape desktop is
expanded by width and does not. So this cannot be folded into the layout
class, and it is not remembered per class either — closing the column in
landscape closes the dock in portrait, because it is the same column.

`window-resized` reported width alone and now reports both, from a
`shell-height` that subtracts the safe-area insets exactly as `shell-width`
subtracts them: on Android the strips the status and navigation bars occupy
are on the axis being measured, so the aspect of the window and the aspect of
the space the interface actually gets are not the same number.

`column_below` is the decision, with two thresholds rather than one. It is
read on every resize event, and a single threshold means a window dragged
along its own diagonal crosses it several times a second while the pointer is
still down. Entering at 1.25 and leaving at 1.15 is a dead band no plausible
drag re-crosses.

The comment on EXPANDED_MIN_WIDTH claimed a tablet in portrait gets the
compact layout. It does not — its panel is about 960 logical pixels across,
which clears 820 — and that mistaken example is the one D-N2 reasoned from.
Corrected in the same breath, since this is the commit that says what
portrait actually changes.
This commit is contained in:
2026-09-06 20:00:25 +02:00
parent 23a061fd80
commit 0e065389db
2 changed files with 181 additions and 11 deletions
+144 -6
View File
@@ -101,8 +101,16 @@ const MAX_DISPLAY_DIM: u32 = 2048;
/// TRACES: FR-UI-1 | FR-UI-2 | M-16 /// TRACES: FR-UI-1 | FR-UI-2 | M-16
/// Width at which the expanded layout appears (FR-UI-1). /// Width at which the expanded layout appears (FR-UI-1).
/// ///
/// Logical pixels, not a device check — a narrow desktop window gets the /// Logical pixels, not a device check: a desktop window dragged under this gets
/// compact layout exactly as a tablet in portrait would. /// the compact layout exactly as a small screen would, and that is FR-UI-1's
/// rule rather than a convenience.
///
/// **The tablet in portrait is not that case**, and it is worth saying so here
/// because this comment used to claim it was. The tablet's panel is about 960
/// logical pixels across in portrait, which clears 820 — so portrait is
/// expanded, and turning the device does not change the class at all. What it
/// changes is which axis the develop column is on, which is `column_below` and
/// D-N7, and nothing to do with this number.
const EXPANDED_MIN_WIDTH: f32 = 820.0; const EXPANDED_MIN_WIDTH: f32 = 820.0;
/// TRACES: FR-UI-1 /// TRACES: FR-UI-1
@@ -127,6 +135,27 @@ const PANEL_MAX_FRACTION: f32 = 0.45;
/// it stay accurate over. /// it stay accurate over.
const PANEL_MIN_WIDTH: f32 = 280.0; const PANEL_MIN_WIDTH: f32 = 280.0;
/// TRACES: FR-UI-1
/// The window aspect — height over width — at which the develop column moves
/// from beside the photograph to under it (D-N7).
///
/// Above 1.0 rather than at it, because a window barely taller than it is wide
/// has nothing to gain. D-N7's arithmetic only comes out for the dock once the
/// photograph left beside a 360px column has become a strip, and 1.25 is where
/// that starts: the tablet in portrait is about 960 × 1500, which is 1.56.
const DOCK_ENTER_ASPECT: f32 = 1.25;
/// And the aspect at which it moves back beside the photograph.
///
/// Below `DOCK_ENTER_ASPECT` rather than equal to it, and that gap is the
/// whole point. This is read on every resize event, so a single threshold
/// means a window dragged along its own diagonal crosses it several times a
/// second and the column jumps from one axis to the other and back while the
/// pointer is still down. The dead band between 1.15 and 1.25 is wide enough
/// that no plausible drag re-crosses it and narrow enough that the flag still
/// answers to the window's shape rather than to its history.
const DOCK_LEAVE_ASPECT: f32 = 1.15;
/// How long after the last change a draft frame is replaced by a sharp one. /// How long after the last change a draft frame is replaced by a sharp one.
/// ///
/// Above the interval between events in a drag, so an ordinary gesture never /// Above the interval between events in a drag, so an ordinary gesture never
@@ -3414,18 +3443,31 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Slint because a property that both derives from and feeds the layout is // Slint because a property that both derives from and feeds the layout is
// a binding loop. // a binding loop.
let panels = std::rc::Rc::new(PanelChoices::default()); let panels = std::rc::Rc::new(PanelChoices::default());
// Where `column-below` stood a moment ago, which is the other half of the
// hysteresis in `column_below`. Not in `PanelChoices`: that records where
// the *user* disagreed with a layout class, and this is neither a class nor
// anything the user has said — it is the last answer, kept so the next one
// can decline to contradict it over a pixel of drag.
let dock = std::rc::Rc::new(std::cell::Cell::new(None::<bool>));
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let panels = panels.clone(); let panels = panels.clone();
window.on_window_resized(move |width| { let dock = dock.clone();
window.on_window_resized(move |width, height| {
let Some(window) = weak.upgrade() else { return }; let Some(window) = weak.upgrade() else { return };
apply_layout_class(&window, width, &panels); apply_layout_class(&window, width, height, &panels, &dock);
}); });
} }
{ {
let size = window.window().size(); let size = window.window().size();
let scale = window.window().scale_factor().max(0.01); let scale = window.window().scale_factor().max(0.01);
apply_layout_class(&window, size.width as f32 / scale, &panels); apply_layout_class(
&window,
size.width as f32 / scale,
size.height as f32 / scale,
&panels,
&dock,
);
} }
// FR-UI-2: the two collapsible columns, opened and closed by hand. // FR-UI-2: the two collapsible columns, opened and closed by hand.
@@ -3606,11 +3648,55 @@ fn groups_in_rail(settings: &settings_ui::SettingsController) -> bool {
.groups_in_rail(dr_plat::is_touch_first()) .groups_in_rail(dr_plat::is_touch_first())
} }
fn apply_layout_class(window: &AppWindow, width: f32, panels: &PanelChoices) { /// TRACES: FR-UI-1
/// Which side of the photograph the develop column belongs on, given the shape
/// of the window and where the answer stood before (D-N7).
///
/// **Not a layout class.** The class is decided by width and says how much room
/// there is; this is decided by aspect and says which way round the room is. A
/// 960-wide portrait window is expanded and wants the dock; a 1500-wide
/// landscape one is expanded and does not — so the two cannot be folded
/// together, and `PanelChoices` does not remember this one. Closing the column
/// in landscape closes the dock in portrait, because it is the same column.
///
/// `previously` is `None` on the first call, before any resize has been
/// reported. There is nothing to be hysteretic about yet, so the entering
/// threshold decides alone: a window that opens inside the dead band opens with
/// the column beside the photograph, which is what every window that is not
/// clearly tall gets.
fn column_below(width: f32, height: f32, previously: Option<bool>) -> bool {
// A window with no width has no aspect. It happens between a minimise and
// the resize that follows it, and answering `false` here would swing the
// column back beside the photograph for the one frame in between.
if width <= 0.0 {
return previously.unwrap_or(false);
}
let aspect = height / width;
match previously {
Some(true) => aspect > DOCK_LEAVE_ASPECT,
_ => aspect >= DOCK_ENTER_ASPECT,
}
}
fn apply_layout_class(
window: &AppWindow,
width: f32,
height: f32,
panels: &PanelChoices,
dock: &std::cell::Cell<Option<bool>>,
) {
let expanded = width >= EXPANDED_MIN_WIDTH; let expanded = width >= EXPANDED_MIN_WIDTH;
window.set_expanded(expanded); window.set_expanded(expanded);
window.set_layout_class(if expanded { "expanded" } else { "compact" }.into()); window.set_layout_class(if expanded { "expanded" } else { "compact" }.into());
// FR-UI-1: which axis the column is on, set from here for the same reason
// the class is — the develop view's frame reads it, so deriving it from
// `root.width` inside that frame would be the binding loop the note on
// `expanded` in `app.slint` describes.
let below = column_below(width, height, dock.get());
dock.set(Some(below));
window.set_column_below(below);
// FR-UI-1: the ceiling on the develop column, computed here for the same // FR-UI-1: the ceiling on the develop column, computed here for the same
// reason the class is — a width that both derives from and feeds the // reason the class is — a width that both derives from and feeds the
// layout is a binding loop in Slint. // layout is a binding loop in Slint.
@@ -3991,4 +4077,56 @@ mod tests {
PointsUpdate::Incompatible PointsUpdate::Incompatible
); );
} }
#[test]
fn a_window_taller_than_it_is_wide_docks_the_column_below() {
// The tablet in portrait at either scale factor D-N7 considers, and a
// desktop window dragged into the same shape. Aspect, not width: the
// first of these is expanded by width and still wants the dock.
assert!(column_below(960.0, 1500.0, None));
assert!(column_below(1097.0, 1714.0, None));
assert!(column_below(700.0, 1100.0, Some(false)));
}
#[test]
fn a_window_wider_than_it_is_tall_keeps_the_column_beside() {
// Landscape, square, and the tablet's other orientation. The last is
// the one that matters: rotating back has to bring the column back.
assert!(!column_below(1500.0, 900.0, Some(false)));
assert!(!column_below(1000.0, 1000.0, Some(true)));
assert!(!column_below(1500.0, 960.0, Some(true)));
}
#[test]
fn a_window_resized_across_square_does_not_flap() {
// The dead band, from both sides. Between 1.15 and 1.25 the answer is
// whatever it already was, which is the whole reason there are two
// thresholds rather than one — a diagonal drag crosses this range for
// as long as the pointer is down.
let width = 1000.0;
for aspect in [1.16, 1.20, 1.24] {
let height = width * aspect;
assert!(
column_below(width, height, Some(true)),
"{aspect} from below"
);
assert!(
!column_below(width, height, Some(false)),
"{aspect} from beside"
);
}
}
#[test]
fn the_first_measurement_has_no_state_to_be_hysteretic_about() {
// Before any resize has been reported there is no previous answer, so
// the entering threshold decides alone and the dead band reads as
// "beside" — the arrangement every window that is not clearly tall
// gets. And a window with no width at all keeps whatever it had, so a
// minimise does not swing the column across for one frame.
assert!(!column_below(1000.0, 1200.0, None));
assert!(column_below(1000.0, 1250.0, None));
assert!(!column_below(0.0, 1200.0, None));
assert!(column_below(0.0, 400.0, Some(true)));
}
} }
+37 -5
View File
@@ -1051,6 +1051,23 @@ export component AppWindow inherits Window {
/// smaller fault than one briefly clipped. /// smaller fault than one briefly clipped.
in property <length> panel-max-width: 520px; in property <length> panel-max-width: 520px;
/// TRACES: FR-UI-1
/// Whether the develop column sits under the photograph rather than beside
/// it (D-N7).
///
/// **Not a layout class.** The class is decided by the window's width and
/// says how much room there is; this is decided by its aspect and says
/// which way round that room is, so a 960-wide portrait window is
/// `expanded` *and* docked while a 1500-wide landscape one is `expanded`
/// and not. Nothing here reads it but the develop view's frame, and
/// nothing remembers it: closing the column in landscape closes the dock
/// in portrait, because it is the same column.
///
/// From Rust for the reason `panel-max-width` above is — the frame reads
/// it to lay itself out, so deriving it from `root.width` in the frame
/// would be a property that both feeds and follows the layout.
in property <bool> column-below: false;
// --- local adjustments (FR-DEV-3) --------------------------------------- // --- local adjustments (FR-DEV-3) ---------------------------------------
/// The false-coloured region map, drawn over the photograph. /// The false-coloured region map, drawn over the photograph.
@@ -1232,7 +1249,7 @@ in property <bool> panel-visible: true;
callback back-requested() -> bool; callback back-requested() -> bool;
callback canvas-resized(int, int); callback canvas-resized(int, int);
callback window-resized(length); callback window-resized(length, length);
/// The width the interface actually has to lay out in. /// The width the interface actually has to lay out in.
/// ///
@@ -1242,8 +1259,24 @@ in property <bool> panel-visible: true;
property <length> shell-width: property <length> shell-width:
root.width - root.safe-area-insets.left - root.safe-area-insets.right; root.width - root.safe-area-insets.left - root.safe-area-insets.right;
// One-way: report width outward, never read layout back into it. /// The height it has, for the same reason and with the same caveat.
changed shell-width => { root.window-resized(self.shell-width); } ///
/// Reported alongside the width because the develop view's frame is
/// decided by the *aspect* of what the interface actually gets, not of the
/// window (D-N7) — and on Android those differ by the height of the status
/// and navigation bars, which is exactly the axis being measured.
property <length> shell-height:
root.height - root.safe-area-insets.top - root.safe-area-insets.bottom;
// One-way: report the size outward, never read layout back into it. Two
// handlers because Slint has no single "geometry changed" hook — the same
// shape as `canvas-resized`'s px-w/px-h below.
changed shell-width => {
root.window-resized(self.shell-width, self.shell-height);
}
changed shell-height => {
root.window-resized(self.shell-width, self.shell-height);
}
// Everything the interface draws lives inside this, for two reasons that // Everything the interface draws lives inside this, for two reasons that
// happen to want the same element. // happen to want the same element.
@@ -1267,8 +1300,7 @@ in property <bool> panel-visible: true;
x: root.safe-area-insets.left; x: root.safe-area-insets.left;
y: root.safe-area-insets.top; y: root.safe-area-insets.top;
width: root.shell-width; width: root.shell-width;
height: root.height - root.safe-area-insets.top height: root.shell-height;
- root.safe-area-insets.bottom;
// Deliberately does *not* focus itself. // Deliberately does *not* focus itself.
// //