Split develop.rs into develop/ by area of behaviour
develop.rs had grown to 9,327 lines covering everything the develop session does: opening a photograph, the parameter-row and curve-widget panel model, mask viewing and editing, mask creation and the rasteriser that turns a mask stack into GPU arrays, spot repairs, scene segmentation, framing and zoom, white-balance sampling, rendering and film choice, and the undo/snapshot history. docs/dev/code-health.md CH-1 names dr-ui's lack of a view layer as the reason every feature kept landing in a handful of files; this is the first of the two pure splits it recommends as easy, no-behaviour-change wins independent of that larger rework. The boundaries follow the file's own sections (several were already marked off with comment headers) and the seams a full read turned up underneath them -- mask storage/rasterisation turned out to be a distinct concern from mask viewing and editing, and rows/tabs/curves from each other, so those split further than the headers alone suggested. Each module stays under about 1,500 lines. Struct fields and the handful of helper methods now called from a sibling module became `pub(super)`, which is strictly narrower than the whole-crate reachability a single file gave them; nothing gained visibility outside `develop`. Tests moved with the code they test, including the few cases where a helper one file's tests needed was itself only defined in another's -- those became shared fixtures in `mod.rs` alongside the `headless`/`read_back`/`grey_session` helpers that already worked that way. `mod.rs` re-exports every item `develop::` callers outside this module used before, so lib.rs, masks_ui.rs and the rest needed no changes.
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
//! The attribute tab strip (ARCH §4.3a, FR-DEV-3a): which tab is active and
|
||||
//! which capabilities it narrows the panel to.
|
||||
#[cfg(test)]
|
||||
use dr_gpu::GpuContext;
|
||||
use dr_pipeline::mask::MaskLayer;
|
||||
use dr_pipeline::OpCapability;
|
||||
|
||||
use crate::ParamRow;
|
||||
|
||||
use super::rows::rows_filtered;
|
||||
use super::session::DevelopSession;
|
||||
|
||||
impl DevelopSession {
|
||||
/// The controls the interface should show.
|
||||
///
|
||||
/// 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> {
|
||||
let caps = self.scoped_capabilities();
|
||||
match self.active_tab {
|
||||
Some(attribute) => rows_filtered(
|
||||
&caps,
|
||||
|op| op.attributes.contains(&attribute),
|
||||
self.curve_channel,
|
||||
),
|
||||
None => rows_filtered(&caps, |_| true, self.curve_channel),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(super) 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.
|
||||
///
|
||||
/// Compose is excluded: its one operation prefers an on-canvas widget and
|
||||
/// is skipped by the row builder, so a Compose tab would be empty of rows
|
||||
/// while `ComposePanel` 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::Compose)
|
||||
.filter(|a| {
|
||||
caps.iter().any(|c| {
|
||||
c.attributes.contains(a)
|
||||
&& !rows_filtered(&caps, |o| o.attributes.contains(a), 0).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)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// Whether the film stock belongs in the group currently on screen.
|
||||
///
|
||||
/// The stock is not a parameter, so it is not a [`ParamRow`] and the tab
|
||||
/// filter that hides every other control never reached it: the picker was
|
||||
/// drawn above the rows in *every* group, so "Kodachrome" sat at the top of
|
||||
/// Light, of Colour and of Detail alike. Three places it does not belong,
|
||||
/// and the one it does was no more prominent than the rest.
|
||||
///
|
||||
/// Answered here rather than in the panel because it is a question about
|
||||
/// the operation — what is this control *about* — and the panel is not
|
||||
/// allowed to know. It asks the descriptor, so a stock that were ever
|
||||
/// re-declared as something other than an effect would move on its own.
|
||||
pub fn film_in_group(&self) -> bool {
|
||||
let Some(active) = self.active_tab else {
|
||||
// "All" shows everything, the stock included.
|
||||
return true;
|
||||
};
|
||||
self.graph
|
||||
.capabilities()
|
||||
.iter()
|
||||
.find(|c| c.id == dr_pipeline::ops::film_sim::ID)
|
||||
.is_some_and(|c| c.attributes.contains(&active))
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
||||
/// The selection's representative layer, for anything that can only show
|
||||
/// one answer — which tab is open, what value a slider currently reads.
|
||||
///
|
||||
/// The first id selected, not "the" active layer: with more than one
|
||||
/// selected there is no single truth to show, and the panel has to pick
|
||||
/// something. Whichever layer this is, [`Self::set_param`] and
|
||||
/// [`Self::reset_op`] still write to every selected layer, not just this
|
||||
/// one — a slider shows one number and applies it everywhere selected.
|
||||
pub(super) fn active_layer(&self) -> Option<&MaskLayer> {
|
||||
let id = self.active_masks.first()?;
|
||||
self.graph.masks().get(id)
|
||||
}
|
||||
|
||||
/// Every selected layer, mutably — what a batched slider or reset walks.
|
||||
pub(super) fn active_layers_mut(&mut self) -> impl Iterator<Item = &mut MaskLayer> {
|
||||
let selected = self.active_masks.clone();
|
||||
self.graph
|
||||
.masks_mut()
|
||||
.layers_mut()
|
||||
.iter_mut()
|
||||
.filter(move |l| selected.contains(&l.id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::develop::test_support::*;
|
||||
|
||||
/// Every attribute the chain actually carries must reach the tab strip.
|
||||
///
|
||||
/// The strip is generated, so an attribute with operations behind it and
|
||||
/// no tab in front of it is unreachable — the controls exist, are in the
|
||||
/// shader, and cannot be filtered to. That is exactly how `Optics` sat
|
||||
/// invisible for as long as it had no operations, and the failure looks
|
||||
/// identical from the outside whether the cause is an empty category or a
|
||||
/// broken filter.
|
||||
#[test]
|
||||
fn every_attribute_with_operations_gets_a_tab() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let session = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
let tabs: Vec<dr_pipeline::Attribute> =
|
||||
session.tabs().into_iter().map(|(a, _)| a).collect();
|
||||
|
||||
for attribute in dr_pipeline::Attribute::ALL {
|
||||
// Compose is deliberately absent: its one stage prefers an
|
||||
// on-canvas widget, so a Compose tab would open onto nothing while
|
||||
// `ComposePanel` holds the real controls.
|
||||
if attribute == dr_pipeline::Attribute::Compose {
|
||||
continue;
|
||||
}
|
||||
let carried = dr_pipeline::EditGraph::default_chain()
|
||||
.capabilities()
|
||||
.iter()
|
||||
.any(|c| c.attributes.contains(&attribute) && !c.params.is_empty());
|
||||
assert_eq!(
|
||||
tabs.contains(&attribute),
|
||||
carried,
|
||||
"{attribute:?}: carried by the chain = {carried}, has a tab = {}",
|
||||
tabs.contains(&attribute)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user