Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions crates/app/src/ui/canvas/readout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
//! keys move is visible where the keys are pressed.
//!
//! It is a label and nothing more: it reads a cached value through
//! [`PlotxApp::contour_base_readout`] and never resolves, measures or queues
//! [`PlotxApp::property_readout`] and never resolves, measures or queues
//! anything. A plot whose estimate has not arrived says so.

use super::{object_screen_rect, plot_rect};
use crate::ui::properties;
use egui::{Align2, Color32, FontId};
use plotx_core::properties::PropertyReadout;
use plotx_core::state::PlotxApp;

const READOUT_FONT_PT: f32 = 11.0;
Expand Down Expand Up @@ -42,13 +43,13 @@ pub(crate) fn paint_property_readouts(
// level while the keys moved the contour that does — or, with several
// contours, present one of them as the whole plot's threshold.
let targets = app.series_targets(ci, object);
let readouts: Vec<_> = app
let readouts: Vec<PropertyReadout> = app
.resolve_property_set(property, &targets)
.applicable_targets
.iter()
.filter_map(|address| app.contour_base_readout(&address.target))
.filter_map(|address| app.property_readout(address).ok())
.collect();
let Some(text) = properties::readout::aggregate_summary(&readouts) else {
let Some(text) = properties::readout::aggregate_property_summary(&readouts) else {
continue;
};
let Some(frame) = object_screen_rect(app.session.board, canvas, object, rect) else {
Expand Down
65 changes: 58 additions & 7 deletions crates/app/src/ui/command_palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::properties::{self, PanelRoute};
use super::*;
use egui::{Align2, FontId, Key, TextEdit, vec2};
use plotx_core::properties::PropertyId;
use plotx_core::state::{ObjectId, PropertyFocus};
use plotx_core::state::{ObjectId, PropertyFocus, ToolGroup};

const PANEL_WIDTH: f32 = 540.0;
const LIST_HEIGHT: f32 = 320.0;
Expand Down Expand Up @@ -175,11 +175,8 @@ pub(super) fn search_set(app: &PlotxApp) -> Vec<PaletteItem> {
.map(PaletteItem::from_command)
.collect();

// Resolved once for the whole property half of the set: applicability is a
// question about the current selection, and every hit asks it of the same
// selection the panel and the other discovery channels read.
let targets = properties::discovery::selection_targets(app);
for hit in properties::property_hits() {
let targets = properties::discovery::targets_for_property(app, hit.id);
let unavailable = property_unavailable_reason(app, hit.id, &targets);
items.push(PaletteItem {
haystack: hit.terms.join(" "),
Expand Down Expand Up @@ -254,11 +251,22 @@ fn property_unavailable_reason(
) -> Option<String> {
let resolved = app.resolve_property_set(property, targets);
if !resolved.applicable_targets.is_empty() {
// The setting applies. Whether it can be *changed* is a separate
// question with its own answer: a locked plot is still described, still
// read out on the canvas, and still findable by name here.
let editable = properties::discovery::editable_targets_for_property(app, property);
if app
.resolve_property_set(property, &editable)
.applicable_targets
.is_empty()
{
return Some(properties::discovery::LOCKED_REASON.to_owned());
}
return None;
}
// Nothing was even a candidate: the group already declares the sentence
// that names the fix, and the catalog has nothing more specific to add.
let Some((_, reason)) = resolved.skipped_targets.first() else {
let Some(skip) = resolved.skipped_targets.first() else {
return Some(
properties::presentation(property)
.and_then(|entry| properties::discovery::group(entry.home_route.section))
Expand All @@ -267,7 +275,10 @@ fn property_unavailable_reason(
.to_owned(),
);
};
Some(format!("Select a series this setting applies to: {reason}"))
Some(format!(
"Select a series this setting applies to: {}",
skip.message
))
}

fn activate(
Expand Down Expand Up @@ -308,10 +319,50 @@ pub(super) fn reveal_property(app: &mut PlotxApp, property: PropertyId, now: f64
}
match route.panel {
PanelRoute::SecondarySidebar => app.session.secondary_sidebar_visible = true,
PanelRoute::Processing => {
app.session.secondary_sidebar_visible = true;
app.session.ui.requested_tool_group = Some(ToolGroup::Processing);
}
}
// A property owned by an owner-local component needs that component opened,
// or the row it names is not on screen to scroll to. The step is chosen here
// — once, from the user's activation — rather than recomputed every frame
// while the panel renders.
if route.panel == PanelRoute::Processing
&& let Some(step) = revealed_step(app, property)
{
app.session.ui.proc_expanded_step = Some(step);
}
app.session.ui.property_focus = Some(PropertyFocus::request(property, now));
}

/// The first processing step that actually carries `property`, addressed the way
/// the catalog addresses it. Applicability is the catalog's answer, so a step
/// whose current settings do not expose the property is passed over rather than
/// opened onto a row that is not there.
fn revealed_step(
app: &PlotxApp,
property: PropertyId,
) -> Option<(plotx_core::state::DatasetId, plotx_processing::StepId)> {
use plotx_core::automation::ComponentRef;
use plotx_core::properties::PropertyAddress;

properties::discovery::targets_for_property(app, property)
.into_iter()
.find(|target| {
app.resolve_property(&PropertyAddress::new(target.clone(), property))
.is_ok()
})
.and_then(|target| match target.component {
Some(ComponentRef::ProcessingStep(step)) => {
plotx_core::state::DatasetId::try_from(&target.resource)
.ok()
.map(|dataset| (dataset, step))
}
_ => None,
})
}

fn filter(items: &[PaletteItem], query: &str) -> Vec<usize> {
let terms: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
items
Expand Down
67 changes: 67 additions & 0 deletions crates/app/src/ui/command_palette_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,70 @@ fn empty_state_is_constructible() {
let state = plotx_core::state::CommandPaletteState::default();
assert!(state.query.is_empty());
}

/// The palette disables a setting it cannot reveal, and the reason has to name
/// the state that blocks it. A locked plot used to disappear from the selection
/// entirely, so the palette reported "Select a plot whose series draws
/// contours…" about a contour plot the user had selected.
#[test]
fn a_locked_selection_disables_a_setting_with_the_reason_that_actually_applies() {
use crate::ui::properties::fixture;

let (mut app, ids) = fixture::contour_page(1);
let targets = properties::discovery::targets_for_property(&app, contour::BASE_MAGNITUDE);
assert!(property_unavailable_reason(&app, contour::BASE_MAGNITUDE, &targets).is_none());

if let Some(object) = app.doc.canvases[0].object_mut(ids[0]) {
object.locked = true;
}
let targets = properties::discovery::targets_for_property(&app, contour::BASE_MAGNITUDE);
let reason = property_unavailable_reason(&app, contour::BASE_MAGNITUDE, &targets)
.expect("a locked plot cannot receive the write");
assert_eq!(reason, properties::discovery::LOCKED_REASON);
assert!(
!reason.contains("draws contours"),
"the plot does draw contours; saying otherwise sends the user to fix the wrong thing"
);
}

/// Revealing a setting that lives on a processing step has to open that step,
/// and has to do it by moving the panel's own expansion state.
///
/// The previous arrangement derived the expansion from the property focus while
/// rendering, so the focus's ~800 ms highlight timer collapsed the row again
/// with no user action behind it — the crate's layout-stability rule forbids
/// exactly that — and, because a focus names a property rather than a step, it
/// opened every step that could carry the setting at once.
#[test]
fn revealing_a_step_setting_opens_that_step_and_leaves_it_open() {
use plotx_core::properties::apodization;

let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default());
app.doc
.datasets
.push(crate::ui::properties::fixture::time_domain_2d());
app.set_active_dataset(Some(0));

let expected = properties::discovery::targets_for_property(&app, apodization::KIND)
.into_iter()
.find_map(|target| match target.component {
Some(plotx_core::automation::ComponentRef::ProcessingStep(step)) => Some(step),
_ => None,
})
.expect("the time-domain factory recipe has an apodization step");

reveal_property(&mut app, apodization::KIND, 10.0);
assert_eq!(
app.session.ui.proc_expanded_step.map(|(_, step)| step),
Some(expected),
"the reveal opens the step that carries the setting"
);

// The highlight expires on a timer. The expansion may not follow it.
app.session.ui.property_focus = None;
assert_eq!(
app.session.ui.proc_expanded_step.map(|(_, step)| step),
Some(expected),
"a timer must never collapse a row the user asked to see"
);
}
55 changes: 51 additions & 4 deletions crates/app/src/ui/figure_typography.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! same contract as the canvas-size fields.

use super::*;
use plotx_core::properties::FloatBounds;
use plotx_figure::FigureTypography;

pub(super) fn figure_typography_window(app: &mut PlotxApp, ctx: &egui::Context) {
Expand All @@ -29,11 +30,23 @@ pub(super) fn figure_typography_window(app: &mut PlotxApp, ctx: &egui::Context)
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
size_row(app, ui, "Tick labels", |t| &mut t.tick_pt);
// The tick size is a catalog property, so its range comes
// from the definition the catalog control and the write path
// are both built from. A literal here would be a second copy
// of the rule, and it was: this window clamped to 24 pt while
// the catalog admitted 72, so any interaction here silently
// pulled a 40 pt figure back down.
size_row(app, ui, "Tick labels", tick_bounds(), |t| &mut t.tick_pt);
ui.end_row();
size_row(app, ui, "Axis titles", |t| &mut t.label_pt);
// The other two are not catalog properties yet and keep the
// range this window has always applied to them.
size_row(app, ui, "Axis titles", UNREGISTERED_BOUNDS, |t| {
&mut t.label_pt
});
ui.end_row();
size_row(app, ui, "Figure title", |t| &mut t.title_pt);
size_row(app, ui, "Figure title", UNREGISTERED_BOUNDS, |t| {
&mut t.title_pt
});
ui.end_row();
});
ui.add_space(8.0);
Expand All @@ -54,10 +67,21 @@ pub(super) fn figure_typography_window(app: &mut PlotxApp, ctx: &egui::Context)
/// One labelled pt-size drag. Live-applies while dragging and commits a single
/// undoable action per gesture (or per typed edit), mirroring
/// `handle_canvas_dimension_response`.
/// The range this window applies to the two sizes that have no catalog entry.
const UNREGISTERED_BOUNDS: FloatBounds = FloatBounds::inclusive(4.0, 24.0);

/// The declared range of the tick-label size, read from its definition.
fn tick_bounds() -> FloatBounds {
plotx_core::properties::definition(plotx_core::properties::typography::TICK_PT)
.and_then(|definition| definition.value_schema.float_bounds())
.unwrap_or(UNREGISTERED_BOUNDS)
}

fn size_row(
app: &mut PlotxApp,
ui: &mut Ui,
label: &str,
bounds: FloatBounds,
field: impl Fn(&mut FigureTypography) -> &mut f32,
) {
ui.label(label);
Expand All @@ -69,7 +93,7 @@ fn size_row(
let resp = ui.add(
egui::DragValue::new(&mut value)
.speed(0.25)
.range(4.0..=24.0)
.range(bounds.lowest()..=bounds.max)
.max_decimals(1)
.suffix(" pt"),
);
Expand All @@ -96,3 +120,26 @@ fn size_row(
app.execute_action(Action::set_figure_typography(frame_before, after));
}
}

#[cfg(test)]
mod tests {
use super::*;

/// One definition of the range. The window used to stop at 24 pt while the
/// catalog admitted 72, so a size set through the catalog was silently
/// clamped the next time this window was touched.
#[test]
fn the_tick_row_takes_its_range_from_the_catalog() {
let declared =
plotx_core::properties::definition(plotx_core::properties::typography::TICK_PT)
.and_then(|definition| definition.value_schema.float_bounds())
.expect("the tick-label size is a registered float property");
let row = tick_bounds();
assert_eq!(row.max, declared.max);
assert_eq!(row.lowest(), declared.lowest());
assert!(
row.admits(40.0),
"a size the catalog accepts must survive a visit to this window"
);
}
}
28 changes: 27 additions & 1 deletion crates/app/src/ui/object_inspector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) {
});
commit_if_target_changed(app, axis_target);
let Some(ci) = app.session.active_canvas else {
crate::ui::properties::panel::typography_section(app, ui);
return;
};
if ci >= app.doc.canvases.len() {
crate::ui::properties::panel::typography_section(app, ui);
return;
}
if ids.is_empty() {
Expand Down Expand Up @@ -107,9 +109,18 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) {
/// cross-target `Mixed` aggregate out of reach of the interface entirely. The
/// section renders nothing at all when no resolved series has an applicable
/// encoding.
/// The typography section is deliberately unconditional: it is a *document*
/// property, so it applies whenever a document is open, whatever is selected.
/// Gating it on the selection would hide an always-applicable control for a
/// transient reason, which the crate's hide-vs-disable rule forbids.
fn property_sections(app: &mut PlotxApp, ci: usize, ui: &mut Ui) {
let objects = crate::ui::properties::discovery::selection_objects(app);
// The write side of the shared selection: these sections carry controls, so
// they take the editable subset. The lock lives in one place rather than
// being re-derived here.
let objects = crate::ui::properties::discovery::editable_objects(app);
crate::ui::properties::panel::contour_section(app, ci, &objects, ui);
crate::ui::properties::panel::line_section(app, ci, &objects, ui);
crate::ui::properties::panel::typography_section(app, ui);
}

fn geometry_section(app: &mut PlotxApp, ci: usize, ids: &[ObjectId], ui: &mut Ui) {
Expand Down Expand Up @@ -768,4 +779,19 @@ mod tests {
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| render(&mut app, ui));
}

#[test]
fn property_target_filter_excludes_locked_plot_objects() {
let (mut app, ids) = crate::ui::properties::fixture::contour_page(1);
let object = ids[0];
app.doc.canvases[0]
.object_mut(object)
.expect("the fixture plot exists")
.locked = true;
let targets = kind_targets(&app, 0, &ids, |candidate| candidate.plot().is_some());
assert!(
targets.is_empty(),
"a locked plot must be excluded before catalog targets are built"
);
}
}
Loading
Loading