Group the panel by what operations say they are about
A strip of groups over the adjust panel — Light, Colour, Detail — derived from the attributes the operations declare. `adjust.slint` names none of them: the strings arrive resolved and the panel only draws them, so a new operation joins the right group by saying what it is and this file does not change (FR-DEV-3a). A group nothing carries is not offered, so a tab never opens onto nothing. Geometry is left out because its one operation prefers an on-canvas widget and is skipped by the row builder — a Geometry tab would be empty while `GeometryPanel` holds the real controls. The strip appears only when there is more than one group to choose between; a single tab is a control with one option. The selected group is underlined rather than filled. The accent means *modified* everywhere else in this interface, and spending it on "which tab" would blunt the one signal the panel has. **The trap, and it nearly bit again.** `op_index` on a row counts over every capability, not over the ones a filter kept — it is how a row routes back to the core. Renumbering it while filtering would make a slider drive a different operation, which looks like a rendering fault rather than a routing one. `rows_filtered` keeps `enumerate` over the full list and only `group_head` is a position within the emitted rows; a test moves a value through a filtered row and checks it lands where it was asked to. Six tests, including that a nonsense index falls back to showing everything rather than to showing nothing.
This commit is contained in:
+195
-15
@@ -86,6 +86,12 @@ pub struct DevelopSession {
|
||||
active_mask: Option<String>,
|
||||
/// Whether to draw the false-coloured region overlay.
|
||||
show_overlay: bool,
|
||||
/// Which attribute the panel is filtered to, or all of them.
|
||||
///
|
||||
/// `None` is "show everything" and is what a frontend that ignores
|
||||
/// attributes leaves it at — the tabs are the interface's idea, not the
|
||||
/// core's, and nothing breaks without them (ARCH §4.3a).
|
||||
active_tab: Option<dr_pipeline::Attribute>,
|
||||
}
|
||||
|
||||
impl DevelopSession {
|
||||
@@ -150,6 +156,7 @@ impl DevelopSession {
|
||||
subject_key: 0,
|
||||
active_mask: None,
|
||||
show_overlay: false,
|
||||
active_tab: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,16 +165,68 @@ impl DevelopSession {
|
||||
/// Built entirely from the capability list. The `kind` string chooses the
|
||||
/// widget; nothing switches on a parameter's identity.
|
||||
pub fn rows(&self) -> Vec<ParamRow> {
|
||||
match self.active_layer() {
|
||||
// A selected layer takes over the panel. The rows are built from
|
||||
// the layer's own capability list, so every control the global
|
||||
// chain offers is offered here too — including ones added later,
|
||||
// which need no work to become local.
|
||||
Some(layer) => rows_from(&layer.capabilities()),
|
||||
None => rows_from(&self.graph.capabilities()),
|
||||
let caps = self.scoped_capabilities();
|
||||
match self.active_tab {
|
||||
Some(attribute) => rows_filtered(&caps, |op| op.attributes.contains(&attribute)),
|
||||
None => rows_from(&caps),
|
||||
}
|
||||
}
|
||||
|
||||
/// The capability list the panel is currently describing.
|
||||
///
|
||||
/// A selected mask layer takes over the panel, so every control the global
|
||||
/// chain offers is offered on a layer too — including operations added
|
||||
/// later, which need no work to become local.
|
||||
fn scoped_capabilities(&self) -> Vec<OpCapability> {
|
||||
match self.active_layer() {
|
||||
Some(layer) => layer.capabilities(),
|
||||
None => self.graph.capabilities(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The attributes worth offering as tabs, in declaration order.
|
||||
///
|
||||
/// **Derived from the chain, never listed here.** The groups are whatever
|
||||
/// the operations say they are about, so a new operation joins the right
|
||||
/// tab by declaring its nature and this file goes on naming none of them
|
||||
/// (FR-DEV-3a). An attribute nothing carries is left out rather than
|
||||
/// offered as a tab that opens onto nothing.
|
||||
///
|
||||
/// Geometry is excluded: its one operation prefers an on-canvas widget and
|
||||
/// is skipped by the row builder, so a Geometry tab would be empty of rows
|
||||
/// while `GeometryPanel` holds the real controls.
|
||||
pub fn tabs(&self) -> Vec<(dr_pipeline::Attribute, String)> {
|
||||
use dr_pipeline::Attribute;
|
||||
let caps = self.scoped_capabilities();
|
||||
Attribute::ALL
|
||||
.into_iter()
|
||||
.filter(|a| *a != Attribute::Geometry)
|
||||
.filter(|a| {
|
||||
caps.iter()
|
||||
.any(|c| c.attributes.contains(a) && !rows_filtered(&caps, |o| o.attributes.contains(a)).is_empty())
|
||||
})
|
||||
.map(|a| (a, crate::labels::resolve(a.label().0)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Which tab is selected, as an index into [`Self::tabs`]. `-1` is "all".
|
||||
pub fn active_tab(&self) -> i32 {
|
||||
let Some(active) = self.active_tab else {
|
||||
return -1;
|
||||
};
|
||||
self.tabs()
|
||||
.iter()
|
||||
.position(|(a, _)| *a == active)
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
|
||||
/// Select a tab by its index in [`Self::tabs`], or `-1` for all.
|
||||
pub fn set_active_tab(&mut self, index: i32) {
|
||||
self.active_tab = usize::try_from(index)
|
||||
.ok()
|
||||
.and_then(|i| self.tabs().get(i).map(|(a, _)| *a));
|
||||
}
|
||||
|
||||
fn active_layer(&self) -> Option<&MaskLayer> {
|
||||
let id = self.active_mask.as_ref()?;
|
||||
self.graph.masks().get(id)
|
||||
@@ -254,8 +313,28 @@ pub(crate) fn supported(widget: WidgetKind) -> bool {
|
||||
/// heard of appearing in a generated panel — and it cannot be asserted at all
|
||||
/// if generating a row requires a device.
|
||||
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
|
||||
rows_filtered(caps, |_| true)
|
||||
}
|
||||
|
||||
/// The panel model for the capabilities `keep` accepts.
|
||||
///
|
||||
/// **`op_index` counts over every capability, not over the kept ones.** It is
|
||||
/// how a row routes back to the core, so filtering must not renumber it — a
|
||||
/// row that survived a filter has to still name the operation it came from.
|
||||
/// `group_head` is the opposite: a position within the *emitted* rows, because
|
||||
/// the panel walks back to it through the model it was given.
|
||||
///
|
||||
/// Getting that backwards is how a slider ends up driving a different
|
||||
/// operation, which is the kind of fault that looks like a rendering bug.
|
||||
pub(crate) fn rows_filtered(
|
||||
caps: &[OpCapability],
|
||||
keep: impl Fn(&OpCapability) -> bool,
|
||||
) -> Vec<ParamRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (op_index, op) in caps.iter().enumerate() {
|
||||
if !keep(op) {
|
||||
continue;
|
||||
}
|
||||
// Where this operation's rows begin. The panel groups by walking
|
||||
// back to it, so it has to be taken before any row is pushed.
|
||||
let group_head = rows.len();
|
||||
@@ -533,10 +612,7 @@ impl DevelopSession {
|
||||
/// here is curve-shaped; it walks whatever parameters the operation
|
||||
/// declares.
|
||||
pub fn reset_op(&mut self, op_index: i32) {
|
||||
let caps = match self.active_layer() {
|
||||
Some(layer) => layer.capabilities(),
|
||||
None => self.graph.capabilities(),
|
||||
};
|
||||
let caps = self.scoped_capabilities();
|
||||
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
|
||||
return;
|
||||
};
|
||||
@@ -1182,10 +1258,10 @@ impl DevelopSession {
|
||||
// last described — the indices the interface is holding are positions
|
||||
// in *that* list, and reading the global chain while a layer is
|
||||
// selected would map a slider onto a different operation.
|
||||
let caps = match self.active_layer() {
|
||||
Some(layer) => layer.capabilities(),
|
||||
None => self.graph.capabilities(),
|
||||
};
|
||||
// The *unfiltered* scoped list, because `op_index` counts over every
|
||||
// capability — see `rows_filtered`. Indexing a filtered list here is
|
||||
// how a slider would drive the wrong operation once a tab is chosen.
|
||||
let caps = self.scoped_capabilities();
|
||||
let op = caps.get(usize::try_from(op_index).ok()?)?;
|
||||
let param = op.params.get(usize::try_from(param_index).ok()?)?;
|
||||
Some((op.id, param.id))
|
||||
@@ -3055,6 +3131,110 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Group tabs (ARCH §4.3a, FR-DEV-3a)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
fn tabbed_session(ctx: &GpuContext) -> DevelopSession {
|
||||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||
DevelopSession::open_rgb(ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||||
.expect("session")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tabs_come_from_the_chain_not_from_a_list() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let session = tabbed_session(&ctx);
|
||||
|
||||
let names: Vec<String> = session.tabs().into_iter().map(|(_, n)| n).collect();
|
||||
assert!(names.contains(&"Light".to_string()), "got {names:?}");
|
||||
assert!(names.contains(&"Colour".to_string()), "got {names:?}");
|
||||
// Nothing offers a group with no rows behind it.
|
||||
for (attribute, name) in session.tabs() {
|
||||
let mut probe = tabbed_session(&ctx);
|
||||
let index = probe
|
||||
.tabs()
|
||||
.iter()
|
||||
.position(|(a, _)| *a == attribute)
|
||||
.expect("just listed");
|
||||
probe.set_active_tab(index as i32);
|
||||
assert!(!probe.rows().is_empty(), "tab {name} opens onto nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_tab_narrows_the_panel() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let mut session = tabbed_session(&ctx);
|
||||
|
||||
let all = session.rows().len();
|
||||
session.set_active_tab(0);
|
||||
let narrowed = session.rows().len();
|
||||
|
||||
assert!(narrowed > 0, "a tab must show something");
|
||||
assert!(narrowed < all, "and less than everything: {narrowed} of {all}");
|
||||
}
|
||||
|
||||
/// The trap: `op_index` counts over *every* capability, so a row that
|
||||
/// survived a filter must still route to the operation it came from. If it
|
||||
/// renumbered, a slider would drive a different operation once a tab was
|
||||
/// chosen.
|
||||
#[test]
|
||||
fn a_filtered_row_still_drives_its_own_operation() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let mut session = tabbed_session(&ctx);
|
||||
|
||||
// Find a colour row while unfiltered, and remember where it points.
|
||||
let colour = session
|
||||
.tabs()
|
||||
.iter()
|
||||
.position(|(_, n)| n == "Colour")
|
||||
.expect("the chain has colour operations");
|
||||
session.set_active_tab(colour as i32);
|
||||
|
||||
let row = session.rows().into_iter().next().expect("a row");
|
||||
let (op, param) = (row.op_index, row.param_index);
|
||||
|
||||
session.set_param(op, param, 0.5);
|
||||
let after = session
|
||||
.rows()
|
||||
.into_iter()
|
||||
.find(|r| r.op_index == op && r.param_index == param)
|
||||
.expect("the row survived");
|
||||
|
||||
assert!(
|
||||
(after.value - 0.5).abs() < 1e-5,
|
||||
"the value landed on the row that asked for it, not another"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_is_reachable_again() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let mut session = tabbed_session(&ctx);
|
||||
let all = session.rows().len();
|
||||
|
||||
session.set_active_tab(0);
|
||||
assert!(session.rows().len() < all);
|
||||
|
||||
session.set_active_tab(-1);
|
||||
assert_eq!(session.rows().len(), all, "-1 means everything");
|
||||
assert_eq!(session.active_tab(), -1);
|
||||
}
|
||||
|
||||
/// An out-of-range index is navigation nonsense, not an edit; it must not
|
||||
/// leave the panel showing nothing.
|
||||
#[test]
|
||||
fn a_nonsense_tab_falls_back_to_everything() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let mut session = tabbed_session(&ctx);
|
||||
let all = session.rows().len();
|
||||
|
||||
session.set_active_tab(99);
|
||||
assert_eq!(session.rows().len(), all);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// `dr_pipeline`'s morphology, as `dr_segment` names it.
|
||||
|
||||
Reference in New Issue
Block a user