A layer offered the film's sliders and they moved nothing: its copy of the node was never given the stock, so it stayed inactive. Film now works in a layer the way the other adjustments do, as offsets to the photograph's settings, but blended as settings rather than as results, since a film is a rendering and cross-fading two developments is not what a region on a pushed film looks like. - dr-film bakes no slider. Exposure is a gain in the shader; push interpolates the stock's measured processes, one curve row each; the print is split at the paper's log exposure, so print exposure is an addition between two lookups and exact at any setting. The enlarger stays balanced at the photograph's exposure. - film_sim reads all four settings as uniforms, format one-hot over a grain count per format, so every uniform is linear in what it does. - Operation::blends_settings lets the composer average each overlapping layer's uniforms with the global ones by mask weight, the global setting taking whatever weight the layers leave, and run the fragment once. Three layers at full weight give the mean of their settings. - The stock picker is hidden on a layer. Only the photograph's exposure re-solves the print balance; push, print exposure and format need no rebake at all now.
293 lines
11 KiB
Rust
293 lines
11 KiB
Rust
//! 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.
|
|
///
|
|
/// Never on a mask layer. A layer holds the film's *settings* — a region
|
|
/// pushed further, or burned in under the enlarger — but the stock is what
|
|
/// the whole photograph was made on, and a picker in a layer's panel would
|
|
/// change it for every pixel while looking as though it changed some.
|
|
pub fn film_in_group(&self) -> bool {
|
|
if self.active_layer().is_some() {
|
|
return false;
|
|
}
|
|
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);
|
|
}
|
|
}
|