From cafa63ca6fc1bc216b31716dae0ed41cc2ddc2cf Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 27 Aug 2026 21:23:44 +0200 Subject: [PATCH] Let the develop column ask how wide it needs to be MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column was 280px, a number chosen for a tablet, with 380px bolted on later for a desktop. Both were guesses at how much room the widest row inside needs, and a guess is what cannot work here: the mode strip is one chip per attribute the *operation set declares*, so the row is generated and no constant in app.slint can track it. When the guess came up short the failure was not a tidy clip. The Flickable inside the column never had its `viewport-width` set, so the viewport took its content's preferred width, and a viewport wider than its Flickable is *centred* in it — the same rule the note on the seam's `x: 0` already records a few lines below. So the column lost half of each edge rather than one of them: "HISTOGRAM" read "ISTOGRAM", "Straighten" read "aighten", Copy sat centred while Paste ran off the far side. It looked like a rendering fault and it was an alignment one. So the column asks instead of guessing. Every panel that can appear in it — image, histogram, geometry, settings transfer, masks, repairs, adjust, history — now publishes a `content-width`: how wide it has to be before it starts clipping itself, read off its own layout rather than asserted. Each declares that as its `min-width` too, and that is what makes the aggregation automatic: `column` is a layout, so it already reports the largest minimum among its children, and it does so for the panels that come and go with the mode as well, which live inside `if`s and cannot be named from outside. Grep `content-width` in ui/dr-ui/ui to see every panel with a say in the answer. The mode strip is named explicitly only because it is pinned outside that layout, so nothing else measures it. There is no floor left. A floor is one more guess and the panels state their own minimums now. The only thing still above the measurement is `panel-max-width`, which is not a size but a policy — a column may not take the window from the photograph it exists to serve — and it comes from Rust beside `layout-class` because a width read from `root.width` inside the layout that `root.width` depends on is a binding loop. Co-Authored-By: Claude Opus 5 (1M context) --- ui/dr-ui/src/lib.rs | 21 ++++++++ ui/dr-ui/ui/adjust.slint | 49 ++++++++++++++++- ui/dr-ui/ui/app.slint | 104 +++++++++++++++++++++++++++++++----- ui/dr-ui/ui/histogram.slint | 16 ++++++ ui/dr-ui/ui/history.slint | 10 +++- ui/dr-ui/ui/masks.slint | 10 +++- ui/dr-ui/ui/spots.slint | 10 +++- 7 files changed, 202 insertions(+), 18 deletions(-) diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 8513354..a4fde87 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -84,6 +84,22 @@ const MAX_DISPLAY_DIM: u32 = 2048; /// compact layout exactly as a tablet in portrait would. const EXPANDED_MIN_WIDTH: f32 = 820.0; +/// TRACES: FR-UI-2 +/// The largest share of the window the develop column may take. +/// +/// The column sizes itself to the widest thing it holds — the generated mode +/// strip, the Copy/Paste pair, the histogram's axis labels — so on a rich +/// operation set it would otherwise keep growing. This is where that stops. +/// The majority of the window stays with the photograph, which is what the +/// column is there to serve. +const PANEL_MAX_FRACTION: f32 = 0.45; + +/// The floor under that share, so a narrow window still gets a usable column +/// rather than one squeezed below the width its own controls were drawn for. +/// Matches the 280px the column asks for at minimum: below this the cap would +/// be doing the clipping the cap exists to avoid. +const PANEL_MIN_WIDTH: f32 = 280.0; + /// 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 @@ -2797,6 +2813,11 @@ fn apply_layout_class(window: &AppWindow, width: f32, panels: &PanelChoices) { window.set_expanded(expanded); window.set_layout_class(if expanded { "expanded" } else { "compact" }.into()); + // FR-UI-2: the ceiling on the develop column, computed here for the same + // reason the class is — a width that both derives from and feeds the + // layout is a binding loop in Slint. + window.set_panel_max_width((width * PANEL_MAX_FRACTION).max(PANEL_MIN_WIDTH)); + if panels.class.get() != Some(expanded) { panels.class.set(Some(expanded)); panels.panel.set(None); diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index 95966e5..6bc7cf7 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -503,6 +503,21 @@ export component GeometryPanel inherits Rectangle { callback angle-reset(); callback reset(); + /// TRACES: FR-UI-2 + /// How wide this panel has to be before it starts clipping itself. + /// + /// Every panel in the develop column declares one, and the column takes + /// the largest — that is the whole of how the column is sized. It replaced + /// two guessed constants (280px for a tablet, 380px for a desktop) that + /// could not track a panel gaining a control, and did not. + /// + /// Published as `min-width` as well as read by name: the first is what + /// makes the enclosing layout aggregate these automatically, including for + /// the panels that come and go with the mode and so cannot be referenced + /// from outside their `if`. + out property content-width: layout.preferred-width; + min-width: root.content-width; + height: layout.preferred-height; layout := VerticalLayout { @@ -623,6 +638,15 @@ export component TransferPanel inherits VerticalLayout { callback copy(); callback paste(); + /// TRACES: FR-UI-2 + /// How wide this panel has to be before it starts clipping itself — the + /// same question every panel in the develop column answers. + /// + /// No `min-width` beside it, unlike the others: this component *is* a + /// layout, so it already reports its own minimum to whatever contains it, + /// and a `min-width` bound to its own preferred size is a binding loop. + out property content-width: self.preferred-width; + padding: Theme.gap; spacing: Theme.gap-sm; @@ -723,6 +747,17 @@ export component ModeStrip inherits Rectangle { callback picked(int); callback mode-picked(ViewMode); + /// How much width this row actually needs, for the column that pins it. + /// + /// Read rather than guessed: the chips are one per mode plus one per group + /// the operation set declares, so the number is generated and no constant + /// in the column could stay in step with it. The Flickable below is still + /// the answer when the column cannot be given that much — it is the floor + /// under a control that must never become unreachable — but it is no longer + /// the *first* answer, which is what made a generated row into a row you + /// had to know to pan. + out property content-width: layout.preferred-width; + background: Theme.surface; height: root.enabled ? layout.preferred-height : 0px; visible: root.enabled; @@ -924,7 +959,19 @@ export component AdjustPanel inherits Rectangle { /// scrolls as one, so the panel reports the drag and the column obeys it. out property slider-dragging: false; - VerticalLayout { + /// TRACES: FR-UI-2 + /// How wide this panel has to be before it starts clipping itself. The + /// develop column is the largest of these and nothing else; publishing it + /// as `min-width` too is what makes the enclosing layout aggregate them + /// without this panel having to be referenced by name. + /// + /// The sliders are the part that matters. Each is a label, a value and a + /// track, and a track squeezed below the width its handle needs is a + /// control that cannot be set accurately — which is the whole job. + out property content-width: layout.preferred-width; + min-width: root.content-width; + + layout := VerticalLayout { padding: Theme.gap; spacing: Theme.gap-sm; alignment: start; diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 8480db3..edbd31d 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -225,6 +225,14 @@ component InfoPanel inherits Rectangle { in property exposure; in property dimensions; + /// TRACES: FR-UI-2 + /// How wide this panel has to be before it starts clipping itself. The + /// develop column is the largest of these and nothing else; publishing it + /// as `min-width` too is what makes the enclosing layout aggregate them + /// without this panel having to be referenced by name. + out property content-width: panel.preferred-width; + min-width: root.content-width; + background: transparent; height: panel.preferred-height; @@ -986,6 +994,22 @@ export component AppWindow inherits Window { in property expanded: true; in property layout-class: "expanded"; + /// TRACES: FR-UI-2 + /// The most the develop column may take, in logical pixels. + /// + /// Set from Rust beside `layout-class` and for the same reason: the column + /// sizes itself to its contents, and a ceiling read from `root.width` + /// inside the layout that `root.width` depends on is the binding loop the + /// note above describes. + /// + /// A ceiling and not a width. The column asks for what it needs and this + /// only says when the answer has stopped being reasonable — a photograph + /// with a third of a window left to show itself in is not a develop view. + /// The default is generous because the first frame is drawn before any + /// resize has been reported, and a column briefly too wide is a far + /// smaller fault than one briefly clipped. + in property panel-max-width: 520px; + // --- local adjustments (FR-DEV-3) --------------------------------------- /// The false-coloured region map, drawn over the photograph. @@ -2494,24 +2518,56 @@ in property panel-visible: true; // depends on `panel-visible`, which derives from the window // width, which the layout then influences. Slint flags it, and // it can panic at runtime. - Rectangle { + develop-column := Rectangle { // TRACES: FR-UI-2 - // 280px was chosen for a tablet, where the column is a - // large fraction of the screen and every pixel of it is - // taken from the photograph. On a desktop window it is - // simply too narrow: the mode strip alone is two modes, - // a separator, "All", and one chip per attribute the - // operation set declares, which is more words than 280px - // holds — so it scrolled sideways, and a control you have - // to pan to reach is one you do not know is there. + // **As wide as what it holds.** // - // Driven off `layout-class` rather than `root.width` + // It was 280px, chosen for a tablet where the column is a + // large fraction of the screen and every pixel of it is + // taken from the photograph. That was too narrow on a + // desktop, so a second number — 380px — was added for the + // expanded class. Both were guesses at how much room the + // widest row in this column needs, and a guess is exactly + // what cannot be right here: the mode strip is two modes, a + // separator, "All", and one chip per attribute the + // operation set *declares*, so the row is generated and no + // constant in this file can track it. When the guess came + // up short the strip scrolled sideways, and a control you + // have to pan to reach is one you do not know is there. + // + // **So the column asks instead.** Every panel that can + // appear in it publishes a `content-width` — how wide it + // has to be before it clips itself — and also declares that + // as its `min-width`. The `min-width` is what makes this + // aggregate on its own: `column` below is a layout, so it + // already reports the largest minimum among its children, + // and it does so for the panels that come and go with the + // mode as well, which sit inside `if`s and cannot be named + // from out here. Grep `content-width` in this directory to + // see every panel that has a say. + // + // The mode strip is named explicitly only because it is + // pinned *outside* that layout, so nothing else would + // measure it. + // + // There is no floor. A floor is another guess, and the + // panels state their own minimums now. The one thing left + // over the measurement is `panel-max-width` — not a size + // but a policy, that a column may not take the window from + // the photograph it exists to serve — and past that the + // Flickables inside are the fallback, as they have always + // been. It comes from Rust rather than from `root.width` // because reading the window width inside the layout that // sets it is the binding loop the comment on `expanded` - // above describes. Rust already classifies the window; this - // just spends the extra width where there is some. + // above describes; Rust already measures the window for + // `layout-class`, so this is one more thing said in the + // same breath. + property content-width: max( + develop-strip.content-width, + column.preferred-width, + ); width: root.panel-visible - ? (root.layout-class == "expanded" ? 380px : 280px) + ? min(root.panel-max-width, self.content-width) : 0px; visible: root.panel-visible; background: Theme.surface; @@ -2538,7 +2594,7 @@ in property panel-visible: true; // would both sit at its origin and overlap. The strip is pinned by // being outside the Flickable rather than by any coordinate. VerticalLayout { - ModeStrip { + develop-strip := ModeStrip { enabled: root.adjust-enabled; mode: root.view-mode; tabs: root.adjust-tabs; @@ -2549,6 +2605,26 @@ in property panel-visible: true; Flickable { viewport-height: column.preferred-height; + // **And the width, or the column is drawn centred + // over its own edges.** + // + // Unset, a Flickable's viewport takes its content's + // preferred width, and a viewport wider than the + // Flickable is *centred* in it — the same rule the + // note on the seam's `x: 0` below records. So a + // column narrower than what it held did not clip + // the right-hand side and keep the left, it lost + // half of each: "HISTOGRAM" read "ISTOGRAM", + // "Straighten" read "aighten", and Paste ran off + // the far side. It looked like a rendering fault + // and it was an alignment one. + // + // Floored at the Flickable's own width so the + // ordinary case — a column sized to fit, which is + // now every case the cap does not bite — puts the + // content at x: 0 and stretches it, rather than + // leaving it preferred-width and adrift. + viewport-width: max(self.width, column.preferred-width); interactive: !adjust.slider-dragging; // **What the column holds is the mode's answer.** diff --git a/ui/dr-ui/ui/histogram.slint b/ui/dr-ui/ui/histogram.slint index d333368..a77967f 100644 --- a/ui/dr-ui/ui/histogram.slint +++ b/ui/dr-ui/ui/histogram.slint @@ -123,6 +123,22 @@ component ClipReadout inherits HorizontalLayout { export component HistogramPanel inherits Rectangle { in property data; + + /// TRACES: FR-UI-2 + /// How wide this panel has to be before it starts clipping itself. + /// + /// Every panel in the develop column declares one, and the column takes + /// the largest — that is the whole of how the column is sized. It replaced + /// two guessed constants (280px for a tablet, 380px for a desktop) that + /// could not track a panel gaining a control, and did not. + /// + /// Published as `min-width` as well as read by name: the first is what + /// makes the enclosing layout aggregate these automatically, including for + /// the panels that come and go with the mode and so cannot be referenced + /// from outside their `if`. + out property content-width: panel.preferred-width; + min-width: root.content-width; + background: transparent; height: panel.preferred-height; diff --git a/ui/dr-ui/ui/history.slint b/ui/dr-ui/ui/history.slint index af404cf..4a79145 100644 --- a/ui/dr-ui/ui/history.slint +++ b/ui/dr-ui/ui/history.slint @@ -115,8 +115,16 @@ export component HistoryPanel inherits Rectangle { // No explicit height either, for the same reason `AdjustPanel` and // `MaskPanel` declare none: the row count changes as the photographer // works, and a height pinned to a layout's preferred size is one more + /// TRACES: FR-UI-2 + /// How wide this panel has to be before it starts clipping itself. The + /// develop column is the largest of these and nothing else; publishing it + /// as `min-width` too is what makes the enclosing layout aggregate them + /// without this panel having to be referenced by name. + out property content-width: layout.preferred-width; + min-width: root.content-width; + // thing that has to keep up with a repeater. - VerticalLayout { + layout := VerticalLayout { padding: Theme.gap; spacing: Theme.gap-sm; alignment: start; diff --git a/ui/dr-ui/ui/masks.slint b/ui/dr-ui/ui/masks.slint index 3eab228..92e8343 100644 --- a/ui/dr-ui/ui/masks.slint +++ b/ui/dr-ui/ui/masks.slint @@ -301,8 +301,16 @@ export component MaskPanel inherits Rectangle { // the "Find regions" button — the sort of fault that is invisible in the // source and obvious the moment anyone looks at the panel. Conditioning // each child on its own keeps every element a direct child of one layout, + /// TRACES: FR-UI-2 + /// How wide this panel has to be before it starts clipping itself. The + /// develop column is the largest of these and nothing else; publishing it + /// as `min-width` too is what makes the enclosing layout aggregate them + /// without this panel having to be referenced by name. + out property content-width: layout.preferred-width; + min-width: root.content-width; + // which is the layout that measures them. - VerticalLayout { + layout := VerticalLayout { padding: Theme.gap; spacing: Theme.gap-sm; alignment: start; diff --git a/ui/dr-ui/ui/spots.slint b/ui/dr-ui/ui/spots.slint index 1e58738..ea9a310 100644 --- a/ui/dr-ui/ui/spots.slint +++ b/ui/dr-ui/ui/spots.slint @@ -85,10 +85,18 @@ export component SpotPanel inherits Rectangle { background: Theme.surface; + /// TRACES: FR-UI-2 + /// How wide this panel has to be before it starts clipping itself. The + /// develop column is the largest of these and nothing else; publishing it + /// as `min-width` too is what makes the enclosing layout aggregate them + /// without this panel having to be referenced by name. + out property content-width: layout.preferred-width; + min-width: root.content-width; + // Flat rather than nested, for the reason `MaskPanel` gives: a nested // conditional layout under-reported its height and drew rows on top of one // another. - VerticalLayout { + layout := VerticalLayout { padding: Theme.gap; spacing: Theme.gap-sm; alignment: start;