diff --git a/docs/traceability.md b/docs/traceability.md index 38bd1b1..dea155d 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -87,7 +87,7 @@ _None._ | FR-UI-1 | [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) | | FR-UI-2 | [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) | | FR-UI-3 | [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | -| FR-UI-4 | [`ui/dr-ui/ui/app.slint:966`](../ui/dr-ui/ui/app.slint#L966) | +| FR-UI-4 | [`ui/dr-ui/ui/app.slint:950`](../ui/dr-ui/ui/app.slint#L950) | | FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1439`](../ui/dr-ui/src/lib.rs#L1439), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1) | diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index 73ebad9..b3b4db3 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -8,7 +8,7 @@ // (FR-DEV-3c). import { Theme } from "theme.slint"; -import { PanelHeading, Label, Value, Caption, Section, Button, IconButton } from "widgets.slint"; +import { PanelHeading, Label, Value, Caption, Button, IconButton } from "widgets.slint"; // One parameter, flattened for Slint's model system. // @@ -63,14 +63,237 @@ export struct ParamRow { points: [float], } -// A slider with a label, value readout, and double-click reset. +// The name of a group of controls, and what can be done to the group. +// +// What is left of `Section` once the collapsing is taken out: the name, the dot +// that says something inside differs from its default, and the reset. The +// sidebar is a column of instruments, and an instrument behind a lid is one the +// user has to remember to open — several of these held a single slider, so the +// lid was most of the row. +// +// The reset stays visible rather than appearing on hover, as the section's did. +// A hover-only control is one no finger can find, and this panel is now +// expected to be worked with a thumb. +component GroupHeading inherits Rectangle { + in property title; + /// Something inside differs from its default. + in property modified: false; + /// Whether this group has anything to reset. + in property has-reset: true; + + callback reset(); + + height: Theme.control-height; + + HorizontalLayout { + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + PanelHeading { + text: root.title; + sub: true; + horizontal-stretch: 1; + } + + Rectangle { + width: 6px; + height: 6px; + y: (parent.height - self.height) / 2; + border-radius: 3px; + background: Theme.modified; + visible: root.modified; + } + + Rectangle { + width: 34px; + visible: root.has-reset; + + reset-touch := TouchArea { + width: 100%; + height: max(parent.height, Theme.touch-target); + y: (parent.height - self.height) / 2; + enabled: root.has-reset; + mouse-cursor: pointer; + clicked => { root.reset(); } + } + + Caption { + text: "reset"; + emphasised: reset-touch.has-hover; + horizontal-alignment: right; + width: 100%; + height: 100%; + } + } + } +} + +// **The** slider. One track, one hit area, one set of gesture rules. +// +// This exists because there used to be two of these, written out separately — +// one reading its geometry from a `ParamRow` for the generated panel, one +// taking plain numbers for the straighten angle — with a comment claiming the +// duplication was safe because "the track behaviour is the same and +// deliberately so". It was not safe and did not stay the same: the moment the +// touch arbitration below was fixed in one copy, the two sliders in the same +// sidebar started behaving differently, and which one you got depended on which +// panel you happened to be dragging in. The wrappers below now differ only in +// where their numbers come from. +component SliderTrack inherits Rectangle { + in property value; + in property default-value; + in property minimum; + in property maximum; + + callback changed(float); + callback reset(); + /// The pointer is on this control. A scrolling ancestor listens so it can + /// stand down — see the note on `engaged` below. + callback engaged-changed(bool); + + height: Theme.touch-target / 2; + + // Guarded, because a descriptor with a zero range would otherwise divide + // by nothing and put every position at infinity. + property span: max(0.000001, root.maximum - root.minimum); + + // **Why hover, and not the drag itself.** + // + // A Flickable does not merely compete for a gesture, it *withholds* the + // press: `DelayForwarding` holds it back for 100ms and only delivers it if + // nothing has claimed the gesture by then. A finger that starts moving + // inside that window therefore leaves this TouchArea never pressed at all — + // so `moved` never fires, and any handler keyed on the drag having started + // can never run. That is why the panel kept taking sliders away from a + // finger while a tap worked perfectly: a tap's release arrives before the + // 100ms is up, so press and release are delivered together, and only a + // *drag* falls in the hole. + // + // Hover is the one signal that does get through. Move events are dispatched + // to children even while the press is withheld, so the moment a finger + // lands on a track and travels a pixel this goes true, the panel sets + // `interactive: false`, and the Flickable stops arbitrating before it can + // capture anything. + // + // The cost is that a drag *starting* on a track no longer scrolls the + // panel. The label above each track and the padding around it still do, and + // the wheel is unaffected — a Flickable handles wheel events whether or not + // it is interactive. + property engaged: area.has-hover || area.claimed; + changed engaged => { root.engaged-changed(root.engaged); } + + // The rail. + Rectangle { + y: (parent.height - 3px) / 2; + height: 3px; + background: Theme.surface-raised; + border-radius: 1.5px; + } + + // The default position, drawn only when it is not at an end. Hand-built + // rather than using the standard Slint slider so this can be marked at + // all — a symmetric control needs to show where zero is. + if root.minimum < root.default-value && root.default-value < root.maximum: Rectangle { + x: (root.default-value - root.minimum) / root.span * parent.width - 1px; + y: (parent.height - 9px) / 2; + width: 2px; + height: 9px; + background: Theme.rule; + } + + // Fill from the default to the current value, so the control shows the + // size and direction of the adjustment rather than an absolute magnitude. + Rectangle { + property default-x: + (root.default-value - root.minimum) / root.span * parent.width; + property value-x: + (root.value - root.minimum) / root.span * parent.width; + + x: min(self.default-x, self.value-x); + width: abs(self.value-x / 1px - self.default-x / 1px) * 1px; + y: (parent.height - 3px) / 2; + height: 3px; + // The fill is the engaged part of the control — the span the + // photographer has actually moved — so it takes `active` rather than + // the ink the rest of the track is drawn in. + background: Theme.active; + border-radius: 1.5px; + } + + Rectangle { + x: (root.value - root.minimum) / root.span * parent.width - 6px; + y: (parent.height - 12px) / 2; + width: 12px; + height: 12px; + border-radius: 6px; + background: area.has-hover || area.pressed ? Theme.ink : Theme.ink-dim; + } + + area := TouchArea { + // Explicitly fill the track. A TouchArea with no geometry collapses to + // zero and only reports the events that happen to land on it, which + // shows up as a slider that clicks but does not drag. + width: 100%; + height: 100%; + + // Whether this gesture has been claimed as a slider drag. + // + // A scrolling panel acts vertically and this control acts + // horizontally, so the axis of the movement says which was meant. + // Committing on press-down instead — the obvious approach — makes + // every attempt to scroll from a slider jump its value first, which is + // destructive and happens constantly given how much of a panel is + // sliders. + property claimed: false; + + function value-at(px: length) -> float { + return clamp( + root.minimum + (px / self.width) * root.span, + root.minimum, + root.maximum); + } + + moved => { + // `moved` fires only while pressed, so this is a drag. + if (!self.claimed + && abs(self.mouse-x - self.pressed-x) + > abs(self.mouse-y - self.pressed-y)) { + self.claimed = true; + } + if (self.claimed) { + root.changed(self.value-at(self.mouse-x)); + } + } + pointer-event(ev) => { + if (ev.kind == PointerEventKind.up + || ev.kind == PointerEventKind.cancel) { + self.claimed = false; + } + // Right-click resets, alongside double-click. + if (ev.kind == PointerEventKind.down + && ev.button == PointerEventButton.right) { + root.reset(); + } + } + clicked => { + // A press with no meaningful drag: jump to it. Handled on release + // rather than on press so it cannot fire during a scroll that + // merely started here. + if (!self.claimed) { + root.changed(self.value-at(self.mouse-x)); + } + } + double-clicked => { root.reset(); } + } +} + +// One generated parameter: a label, a readout, and the track above. component ParamSlider inherits Rectangle { in property data; callback changed(float); callback reset(); - /// Whether a drag has been claimed as a slider gesture. The panel listens - /// so it can stand its Flickable down for the rest of the gesture — see - /// the note on `claimed` below. + /// Forwarded from the track, for the panel's Flickable. callback drag-changed(bool); height: 46px; @@ -101,151 +324,25 @@ component ParamSlider inherits Rectangle { } } - // The track. Hand-built rather than using the standard Slint slider - // so the neutral point can be marked — a symmetric control needs to - // show where zero is. - track := Rectangle { - height: Theme.touch-target / 2; + SliderTrack { + value: root.data.value; + default-value: root.data.default-value; + minimum: root.data.minimum; + maximum: root.data.maximum; - Rectangle { - y: (parent.height - 3px) / 2; - height: 3px; - background: Theme.surface-raised; - border-radius: 1.5px; - } - - // The default position, drawn only when it is not at an end. - if root.data.minimum < root.data.default-value - && root.data.default-value < root.data.maximum: Rectangle { - x: (root.data.default-value - root.data.minimum) - / (root.data.maximum - root.data.minimum) * parent.width - 1px; - y: (parent.height - 9px) / 2; - width: 2px; - height: 9px; - background: Theme.rule; - } - - // Fill from the default to the current value, so the control - // shows the size and direction of the adjustment rather than an - // absolute magnitude. - Rectangle { - property span: root.data.maximum - root.data.minimum; - property default-x: - (root.data.default-value - root.data.minimum) / self.span * parent.width; - property value-x: - (root.data.value - root.data.minimum) / self.span * parent.width; - - x: min(self.default-x, self.value-x); - width: abs(self.value-x / 1px - self.default-x / 1px) * 1px; - y: (parent.height - 3px) / 2; - height: 3px; - // The fill is the engaged part of the control — the span the - // photographer has actually moved — so it takes `active` - // rather than the ink the rest of the track is drawn in. - background: Theme.active; - border-radius: 1.5px; - } - - handle := Rectangle { - x: (root.data.value - root.data.minimum) - / (root.data.maximum - root.data.minimum) * parent.width - 6px; - y: (parent.height - 12px) / 2; - width: 12px; - height: 12px; - border-radius: 6px; - background: area.has-hover || area.pressed ? Theme.ink : Theme.ink-dim; - } - - area := TouchArea { - // Explicitly fill the track. A TouchArea with no geometry - // collapses to zero and only reports the events that happen - // to land on it, which shows up as a slider that clicks but - // does not drag. - width: 100%; - height: 100%; - - property span: root.data.maximum - root.data.minimum; - - // Whether this gesture has been claimed as a slider drag. - // - // The panel scrolls vertically and this control acts - // horizontally, so the axis of the movement says which was - // meant. Committing on press-down instead — the obvious - // approach — makes every attempt to scroll from a slider - // jump its value first, which is destructive and happens - // constantly given how much of the panel is sliders. - // - // **Claiming has to stand the Flickable down, not just gate - // this handler.** Slint's Flickable steals any gesture that - // wanders more than 8 logical pixels along its scrolling axis - // within half a second of the press, and it steals it by - // cancelling the child — so a drag that began here is taken - // away mid-motion. A mouse tracks a horizontal line closely - // enough to stay under that; a finger does not, which is - // exactly why these worked on the desktop and not on the - // tablet. Reporting the claim lets the panel set - // `interactive: false` for the rest of the gesture, after - // which the Flickable stops arbitrating and the drag survives - // however far the finger drifts. - property claimed: false; - changed claimed => { root.drag-changed(self.claimed); } - - function value-at(px: length) -> float { - return clamp( - root.data.minimum + (px / self.width) * self.span, - root.data.minimum, - root.data.maximum); - } - - moved => { - // `moved` fires only while pressed, so this is a drag. - if (!self.claimed) { - // Claim once the movement is more horizontal than - // vertical. Until then it might still be a scroll. - if (abs(self.mouse-x - self.pressed-x) - > abs(self.mouse-y - self.pressed-y)) { - self.claimed = true; - } - } - if (self.claimed) { - root.changed(self.value-at(self.mouse-x)); - } - } - pointer-event(ev) => { - if (ev.kind == PointerEventKind.up - || ev.kind == PointerEventKind.cancel) { - self.claimed = false; - } - // Right-click resets, alongside double-click. - if (ev.kind == PointerEventKind.down - && ev.button == PointerEventButton.right) { - root.reset(); - } - } - clicked => { - // A press with no meaningful drag: jump to it. Handled - // on release rather than on press so it cannot fire - // during a scroll that merely started here. - if (!self.claimed) { - root.changed(self.value-at(self.mouse-x)); - } - } - double-clicked => { - root.reset(); - } - } + changed(v) => { root.changed(v); } + reset => { root.reset(); } + engaged-changed(on) => { root.drag-changed(on); } } } } -// A slider not backed by a `ParamRow`. +// A slider the interface names itself, rather than one generated from a row. // -// `ParamSlider` reads its geometry out of a model row, which is right for the -// generated panel and wrong for a control the interface names itself — the -// straighten angle is reached through the session's own accessor, not through -// a row index, so there is no row to feed it. The track behaviour is the same -// and deliberately so: a slider that dragged differently depending on which -// panel it sat in would be a worse inconsistency than the duplication. +// The straighten angle is reached through the session's own accessor, not +// through a row index, so there is no `ParamRow` to feed it. Only the labels +// and the source of the numbers differ — the track is the same component, and +// so is every gesture it recognises. component PlainSlider inherits Rectangle { in property label; in property value; @@ -256,6 +353,7 @@ component PlainSlider inherits Rectangle { callback changed(float); callback reset(); + callback drag-changed(bool); height: 46px; @@ -278,98 +376,15 @@ component PlainSlider inherits Rectangle { } } - track := Rectangle { - height: Theme.touch-target / 2; + SliderTrack { + value: root.value; + default-value: root.default-value; + minimum: root.minimum; + maximum: root.maximum; - Rectangle { - y: (parent.height - 3px) / 2; - height: 3px; - background: Theme.surface-raised; - border-radius: 1.5px; - } - - // The neutral point. A straighten slider is symmetric and read as - // a deviation from level, so where zero sits has to be visible. - if root.minimum < root.default-value && root.default-value < root.maximum: - Rectangle { - x: (root.default-value - root.minimum) - / (root.maximum - root.minimum) * parent.width - 1px; - y: (parent.height - 9px) / 2; - width: 2px; - height: 9px; - background: Theme.rule; - } - - Rectangle { - property span: root.maximum - root.minimum; - property default-x: - (root.default-value - root.minimum) / self.span * parent.width; - property value-x: - (root.value - root.minimum) / self.span * parent.width; - - x: min(self.default-x, self.value-x); - width: abs(self.value-x / 1px - self.default-x / 1px) * 1px; - y: (parent.height - 3px) / 2; - height: 3px; - background: Theme.active; - border-radius: 1.5px; - } - - Rectangle { - x: (root.value - root.minimum) - / (root.maximum - root.minimum) * parent.width - 6px; - y: (parent.height - 12px) / 2; - width: 12px; - height: 12px; - border-radius: 6px; - background: area.has-hover || area.pressed ? Theme.ink : Theme.ink-dim; - } - - area := TouchArea { - width: 100%; - height: 100%; - - property span: root.maximum - root.minimum; - // The same axis test `ParamSlider` makes, and for the same - // reason: this sits in a scrolling panel, and claiming the - // gesture on press-down would jump the angle every time the - // user tried to scroll from it. - property claimed: false; - - function value-at(px: length) -> float { - return clamp( - root.minimum + (px / self.width) * self.span, - root.minimum, - root.maximum); - } - - moved => { - if (!self.claimed - && abs(self.mouse-x - self.pressed-x) - > abs(self.mouse-y - self.pressed-y)) { - self.claimed = true; - } - if (self.claimed) { - root.changed(self.value-at(self.mouse-x)); - } - } - pointer-event(ev) => { - if (ev.kind == PointerEventKind.up - || ev.kind == PointerEventKind.cancel) { - self.claimed = false; - } - if (ev.kind == PointerEventKind.down - && ev.button == PointerEventButton.right) { - root.reset(); - } - } - clicked => { - if (!self.claimed) { - root.changed(self.value-at(self.mouse-x)); - } - } - double-clicked => { root.reset(); } - } + changed(v) => { root.changed(v); } + reset => { root.reset(); } + engaged-changed(on) => { root.drag-changed(on); } } } } @@ -414,69 +429,76 @@ export component GeometryPanel inherits Rectangle { spacing: 0px; alignment: start; - Section { + // A heading, not a collapsible. + // + // These controls are the reason the column is open; folding them away + // behind a triangle put the sidebar's own contents one tap further + // from the photograph and gave every group a lid that had to be + // learned. `GroupHeading` keeps what the section was actually for — + // naming the group, flagging that it holds an edit, and offering the + // reset — without the hiding. + GroupHeading { title: "GEOMETRY"; modified: root.modified; has-reset: root.modified; - op-reset => { root.reset(); } + reset => { root.reset(); } + } - VerticalLayout { + VerticalLayout { + spacing: Theme.gap-sm; + padding-bottom: Theme.gap-sm; + + // Crop first: it is the framing decision the others serve. + Button { + text: root.crop-mode ? "Done Cropping" : "Crop"; + active: root.crop-mode; + enabled: root.enabled; + clicked => { root.crop-toggled(!root.crop-mode); } + } + + // Rotation and flips. Glyphs rather than labels: four controls + // named in words would wrap the 280px column, and each of + // these shows its own result. + HorizontalLayout { spacing: Theme.gap-sm; - padding-top: Theme.gap-sm; - padding-bottom: Theme.gap-sm; - // Crop first: it is the framing decision the others serve. - Button { - text: root.crop-mode ? "Done Cropping" : "Crop"; - active: root.crop-mode; + IconButton { + glyph: "⟲"; enabled: root.enabled; - clicked => { root.crop-toggled(!root.crop-mode); } + clicked => { root.rotate(-1); } + } + IconButton { + glyph: "⟳"; + enabled: root.enabled; + clicked => { root.rotate(1); } } - // Rotation and flips. Glyphs rather than labels: four controls - // named in words would wrap the 280px column, and each of - // these shows its own result. - HorizontalLayout { - spacing: Theme.gap-sm; + Rectangle { horizontal-stretch: 1; } - IconButton { - glyph: "⟲"; - enabled: root.enabled; - clicked => { root.rotate(-1); } - } - IconButton { - glyph: "⟳"; - enabled: root.enabled; - clicked => { root.rotate(1); } - } - - Rectangle { horizontal-stretch: 1; } - - IconButton { - glyph: "⇔"; - active: root.flip-h; - enabled: root.enabled; - clicked => { root.flip-h-toggled(); } - } - IconButton { - glyph: "⇕"; - active: root.flip-v; - enabled: root.enabled; - clicked => { root.flip-v-toggled(); } - } + IconButton { + glyph: "⇔"; + active: root.flip-h; + enabled: root.enabled; + clicked => { root.flip-h-toggled(); } } - - PlainSlider { - label: "Straighten"; - value: root.angle; - default-value: 0.0; - minimum: -root.max-straighten; - maximum: root.max-straighten; - unit: "°"; - changed(v) => { root.angle-changed(v); } - reset => { root.angle-reset(); } + IconButton { + glyph: "⇕"; + active: root.flip-v; + enabled: root.enabled; + clicked => { root.flip-v-toggled(); } } } + + PlainSlider { + label: "Straighten"; + value: root.angle; + default-value: 0.0; + minimum: -root.max-straighten; + maximum: root.max-straighten; + unit: "°"; + changed(v) => { root.angle-changed(v); } + reset => { root.angle-reset(); } + } } } } @@ -497,15 +519,19 @@ component CurveEditor inherits Rectangle { callback point-moved(int, float, float); callback reset(); - /// A point is being dragged. The panel stands its Flickable down while it - /// is, for the reason spelled out on `ParamSlider`'s `claimed` — and more - /// acutely here, because a curve point is dragged *vertically*, which is - /// the Flickable's own axis and so is stolen every single time. + /// The pointer is on a control point. The panel stands its Flickable down + /// while it is, for the reason spelled out on `ParamSlider`'s `engaged` — + /// and more acutely here, because a curve point is dragged *vertically*, + /// which is the Flickable's own axis and so is contested every time. callback drag-changed(bool); property point-count: root.data.points.length / 2; - changed active-point => { root.drag-changed(root.active-point >= 0); } + // Hover, not the drag: `active-point` is set on press, and the press is + // exactly what a Flickable withholds. Only the plot's grab targets count, + // so the rest of the plot still scrolls the panel. + property engaged: root.active-point >= 0 || root.hovered-point >= 0; + changed engaged => { root.drag-changed(root.engaged); } // The point the pointer is over, or -1. Set by the grab targets below, // and used only to highlight the marker. @@ -636,21 +662,20 @@ component CurveEditor inherits Rectangle { } } -// The panel: a collapsible section per operation, a control per parameter. +// The panel: a heading per multi-parameter operation, a control per parameter. // // **Why the loop is shaped the way it is.** `rows` is flat, and Slint can // neither slice a model nor nest a `for` over a run of it. What it *can* do is // repeat over an integer — `for n in row.group-len` — so each group's heading // row renders its whole group by indexing back into `rows` from `group-head`, -// and every other row renders nothing. That puts the group's controls genuinely -// *inside* its `Section`, which is what makes collapse a matter of the section -// clipping its own body rather than each row hiding itself. +// and every other row renders nothing. // -// It also means collapse state is the `Section`'s own — one `expanded` per -// repeated element, keyed by position and so by `op-index`, never by label. -// Slint keeps that state across row-*data* updates, which is what lets a -// collapsed section stay collapsed while a slider elsewhere is dragged: the -// panel is re-fed on every drag event. +// **Nothing here collapses.** Every group was a `Section` with a disclosure +// triangle until it became clear what that cost on a tablet: five of the +// pipeline's operations carry one parameter, so the lid was most of the row, +// and a control behind a lid is one the user does not know the pipeline has. +// The column is closed as a whole from the status strip instead, which is the +// control that was actually wanted. export component AdjustPanel inherits Rectangle { in property <[ParamRow]> rows; in property enabled: true; @@ -709,20 +734,73 @@ export component AdjustPanel inherits Rectangle { for row[i] in root.rows: VerticalLayout { spacing: 0px; + // **A group of one is not a group.** + // + // Five of the pipeline's operations carry a single + // parameter — exposure, contrast, saturation, vibrance, + // brilliance — and wrapping each in a Section produced a + // collapsible heading, a disclosure triangle, a modified + // dot and a hover reset around one slider, with the + // operation's name printed in caps directly above the same + // word as the slider's own label. Five times over, that is + // a column that reads as chrome with controls hidden in it. + // + // So a lone parameter is drawn bare. It loses the group + // reset, which cost nothing: the slider already resets on + // double-click and right-click, and the section's reset was + // a hover-only affordance no finger could reach anyway. + // + // `row` is the entry here — the group's head is its only + // member — so there is nothing to index back into. + if row.group-head == i && row.group-len == 1: VerticalLayout { + spacing: 0px; + + if row.kind == "scalar": ParamSlider { + data: row; + drag-changed(on) => { root.slider-dragging = on; } + changed(v) => { + root.param-changed( + row.op-index, row.param-index, v); + } + reset => { + root.param-reset(row.op-index, row.param-index); + } + } + + if row.kind == "curve": CurveEditor { + data: row; + samples: root.curve-samples; + drag-changed(on) => { root.slider-dragging = on; } + point-moved(point, x, y) => { + root.param-changed( + row.op-index, row.param-index + point * 2, x); + root.param-changed( + row.op-index, row.param-index + point * 2 + 1, y); + } + reset => { root.curve-reset(row.op-index); } + } + } + // `if` rather than a zero height: a hidden-but-present // section would still *build* its whole group, so every // control would exist once per row of its own group — // thirty-six live TouchAreas behind the colour mixer's // twelve visible ones. The conditional builds nothing. - if row.group-head == i: Section { - title: row.op-label; - modified: row.group-modified; + if row.group-head == i && row.group-len > 1: VerticalLayout { + spacing: 0px; + padding-top: Theme.gap-sm; - // The section's reset. Placed here rather than in - // `Section` itself because resetting is what *this* - // panel's sections do; a section in another panel may - // have nothing to reset. - op-reset => { root.op-reset(row.op-index); } + // A heading rather than a lid. Several of these groups + // are two sliders; hiding two sliders behind a triangle + // costs more than it saves, and a control the user + // cannot see is one they do not know the pipeline has. + GroupHeading { + title: row.op-label; + modified: row.group-modified; + // Resetting is what *this* panel's groups do; the + // heading itself has no opinion about it. + reset => { root.op-reset(row.op-index); } + } // The group's own rows, addressed by offset from its // head. `root.rows[...]` rather than the loop's `row`: diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index b12a9dd..e8e89bf 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -2,7 +2,7 @@ import { Theme } from "theme.slint"; import { AdjustPanel, GeometryPanel, ParamRow } from "adjust.slint"; import { LaunchScreen } from "launch.slint"; import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint"; -import { Button, Label, Value, Caption, Section, EmptyState } from "widgets.slint"; +import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint"; import { CollectionsPanel, CollectionRow } from "collections.slint"; import { SettingsPage } from "settings.slint"; @@ -117,51 +117,35 @@ component StatusBar inherits Rectangle { // Capture metadata. Read-only; the adjustment controls live in AdjustPanel, // which is generated from pipeline capabilities rather than written here. // -// A `Section` rather than the flat `Panel` it used to be, so this collapses -// like everything else in the column does. It was the one group that could not -// be put away, and it is also the one whose content the user stops needing -// first: which body took the photograph is read once and then occupies the top -// of the panel for the rest of the edit. Structurally identical to -// `GeometryPanel` below — same wrapper, same height binding — so the two read -// as the same kind of thing, which they now are. -// -// `has-reset` is off: there is nothing here to undo. The section's heading -// keeps its own collapse state across model updates, which is what lets a -// closed IMAGE stay closed while a slider elsewhere is dragged. +// Flat, like everything else in this column. This was briefly a collapsible +// section so that every panel could be put away; the sidebar as a whole now +// closes from the status strip instead, which is the control that was actually +// wanted, and the per-group lids only stood between the user and the controls. component InfoPanel inherits Rectangle { in property camera; in property exposure; in property dimensions; background: transparent; - height: layout.preferred-height; + height: panel.preferred-height; - layout := VerticalLayout { - spacing: 0px; - alignment: start; + panel := Panel { + // Flat: this abuts the adjust panel below it and the rule between + // them is drawn by the column that stacks the two. + flat: true; + width: 100%; - Section { - title: "IMAGE"; - has-reset: false; + PanelHeading { text: "IMAGE"; } - VerticalLayout { - spacing: Theme.gap-sm; - padding-top: Theme.gap-sm; - padding-bottom: Theme.gap-sm; - padding-left: Theme.gap-sm; - padding-right: Theme.gap-sm; - - Value { - text: root.camera == "" ? "—" : root.camera; - placeholder: root.camera == ""; - wrap: word-wrap; - } - - Label { text: root.exposure; } - - Caption { text: root.dimensions; } - } + Value { + text: root.camera == "" ? "—" : root.camera; + placeholder: root.camera == ""; + wrap: word-wrap; } + + Label { text: root.exposure; } + + Caption { text: root.dimensions; } } } diff --git a/ui/dr-ui/ui/settings.slint b/ui/dr-ui/ui/settings.slint index 6ab15e8..45706ee 100644 --- a/ui/dr-ui/ui/settings.slint +++ b/ui/dr-ui/ui/settings.slint @@ -1,5 +1,5 @@ import { Theme } from "theme.slint"; -import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Section } from "widgets.slint"; +import { Button, PanelHeading, Label, Value, Caption, Panel, Field } from "widgets.slint"; // Settings: how much disk the app may spend, and what an export defaults to. //