//! 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 { 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 { 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 { 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 = (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 = 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 = (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 = 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); } }