use std::collections::{BTreeMap, BTreeSet};
use ironlab_ir::overlay::Overlay;
use ironlab_ir::{
Artist, Axes, Cell, Choice, DataId, Dimension, Edit, Figure, Limits, NodeId, NodeKind,
Parameter, Projection, Property, PropertyPath, Transaction, Value, ValueType, command,
properties, property_choices,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRow {
pub node: NodeId,
pub kind: NodeKind,
pub label: String,
pub depth: usize,
pub dimmed: bool,
}
#[must_use]
pub fn tree_rows(figure: &Figure) -> Vec<TreeRow> {
let mut rows = vec![TreeRow {
node: figure.id,
kind: NodeKind::Figure,
label: row_label(NodeKind::Figure, text_label(figure.title.as_ref())),
depth: 0,
dimmed: false,
}];
for axes in &figure.axes {
rows.push(TreeRow {
node: axes.id,
kind: NodeKind::Axes,
label: row_label(
NodeKind::Axes,
Some(text_label(axes.title.as_ref()).unwrap_or_else(|| cell_name(axes))),
),
depth: 1,
dimmed: false,
});
for artist in &axes.artists {
let kind = artist_kind(artist);
rows.push(TreeRow {
node: artist.id(),
kind,
label: row_label(kind, text_label(artist.display_name())),
depth: 2,
dimmed: !artist.visible(),
});
}
}
rows
}
fn row_label(kind: NodeKind, name: Option<String>) -> String {
match name {
Some(name) => format!("{} ({name})", kind_name(kind)),
None => kind_name(kind).to_owned(),
}
}
#[must_use]
pub fn kind_name(kind: NodeKind) -> &'static str {
match kind {
NodeKind::Figure => "Figure",
NodeKind::Axes => "Axes",
NodeKind::Line => "Line",
NodeKind::Scatter => "Scatter",
NodeKind::Contour => "Contour",
NodeKind::Quiver => "Quiver",
NodeKind::Surface => "Surface",
}
}
fn text_label(text: Option<&ironlab_ir::Text>) -> Option<String> {
let content = text?.content.trim();
(!content.is_empty()).then(|| content.to_owned())
}
fn cell_name(axes: &Axes) -> String {
let Cell { row, col, .. } = axes.cell;
format!("row {row}, col {col}")
}
fn artist_kind(artist: &Artist) -> NodeKind {
match artist {
Artist::Line(_) => NodeKind::Line,
Artist::Scatter(_) => NodeKind::Scatter,
Artist::Contour(_) => NodeKind::Contour,
Artist::Quiver(_) => NodeKind::Quiver,
Artist::Surface(_) => NodeKind::Surface,
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Editor {
Bool,
Number {
speed: f64,
range: Option<(f64, f64)>,
integer: bool,
},
Text,
RichText,
Choice {
offered: Vec<Choice>,
},
Color,
Numbers,
Parameters,
Data {
shape: Option<Vec<usize>>,
},
Group,
ReadOnly {
reason: &'static str,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct PropertyRow {
pub path: PropertyPath,
pub label: String,
pub depth: usize,
pub value: Value,
pub value_type: ValueType,
pub optional: bool,
pub docs: &'static str,
pub editor: Editor,
pub overridden: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PropertyGroup {
pub name: String,
pub rows: Vec<PropertyRow>,
}
fn order_by_name<T>(items: &mut [T], name: impl Fn(&T) -> &str) {
items.sort_by_key(|item| name(item).to_lowercase());
}
#[must_use]
pub fn property_groups(figure: &Figure, overlay: &Overlay, node: NodeId) -> Vec<PropertyGroup> {
let Some(kind) = figure.node_kind(node) else {
return Vec::new();
};
let overridden: BTreeSet<&PropertyPath> = overlay
.entries()
.iter()
.filter(|entry| entry.node == node)
.map(|entry| &entry.path)
.collect();
let mut covered: Vec<PropertyPath> = Vec::new();
let mut rows: Vec<PropertyRow> = Vec::new();
for property in properties(kind) {
if !is_shown(figure, node, kind, &property.path) {
continue;
}
if covered
.iter()
.any(|above| *above != property.path && above.contains(&property.path))
{
continue;
}
let Ok(value) = figure.get(node, &property.path) else {
continue;
};
let editor = editor_for(figure, kind, node, &property, &value);
if matches!(editor, Editor::RichText | Editor::Color) {
covered.push(property.path.clone());
}
let segments = property.path.segments();
rows.push(PropertyRow {
label: segments[1..].join("."),
depth: segments.len() - 1,
overridden: overridden.contains(&property.path),
path: property.path,
value,
value_type: property.value_type,
optional: property.optional,
docs: property.docs,
editor,
});
}
let containers: BTreeSet<PropertyPath> = rows
.iter()
.filter(|row| row.editor == Editor::Group)
.filter(|row| {
rows.iter()
.any(|other| other.path != row.path && row.path.contains(&other.path))
})
.map(|row| row.path.clone())
.collect();
rows.retain(|row| !containers.contains(&row.path));
let mut groups: Vec<PropertyGroup> = Vec::new();
for row in rows {
let name = row.path.segments()[0].clone();
match groups.iter_mut().find(|group| group.name == name) {
Some(group) => group.rows.push(row),
None => groups.push(PropertyGroup {
name,
rows: vec![row],
}),
}
}
order_by_name(&mut groups, |group| &group.name);
for group in &mut groups {
order_by_name(&mut group.rows, |row| &row.label);
}
groups
}
#[must_use]
pub fn is_shown(figure: &Figure, node: NodeId, kind: NodeKind, path: &PropertyPath) -> bool {
if kind != NodeKind::Axes || path.segments().first().is_none_or(|first| first != "z") {
return true;
}
figure
.axes(node)
.is_none_or(|axes| matches!(axes.projection, Projection::ThreeD { .. }))
}
const SCATTER_MARKER_SIZE_REASON: &str = "A scatter sizes its markers by its own size \
property, in the row named size above, which overrides this one. Change size to give \
every marker the same size, or to take each marker's size from an array.";
const TILE_LAYOUT_REASON: &str = "The tile layout is set by the program that builds the \
figure, together with the axes placed in it. The editor changes the properties of \
the objects a figure has, not which objects there are or where they sit.";
const CELL_REASON: &str = "The cell an axes occupies, like the tile layout it sits in, is \
set by the program that builds the figure. The editor changes how a figure looks, \
not how it is arranged.";
const LINKS_REASON: &str = "The groups of axes whose limits are linked are set by the \
program that builds the figure, as part of how its axes relate to one another.";
pub const DATA_REASON: &str = "The data a plot draws comes from the program that builds \
the figure, which is where it is changed. The editor changes how the figure looks, \
not what it draws.";
#[must_use]
pub fn read_only_reason(kind: NodeKind, path: &PropertyPath) -> Option<&'static str> {
let segments = path.segments();
match kind {
NodeKind::Figure => {
if segments == ["layout", "rows"] || segments == ["layout", "cols"] {
Some(TILE_LAYOUT_REASON)
} else if segments == ["links"] {
Some(LINKS_REASON)
} else {
None
}
}
NodeKind::Scatter if segments == ["marker", "size_pt"] => Some(SCATTER_MARKER_SIZE_REASON),
NodeKind::Axes if segments.first().is_some_and(|first| first == "cell") => {
Some(CELL_REASON)
}
NodeKind::Axes
| NodeKind::Line
| NodeKind::Scatter
| NodeKind::Contour
| NodeKind::Quiver
| NodeKind::Surface => None,
}
}
#[must_use]
pub fn is_composite(value_type: ValueType) -> bool {
match value_type {
ValueType::FigureSize
| ValueType::TileLayout
| ValueType::Cell
| ValueType::View3d
| ValueType::Axis
| ValueType::Legend
| ValueType::LineStyle
| ValueType::MarkerStyle => true,
ValueType::Bool
| ValueType::UInt32
| ValueType::Double
| ValueType::Float
| ValueType::String
| ValueType::DataId
| ValueType::Doubles
| ValueType::Text
| ValueType::Interpreter
| ValueType::FontSetId
| ValueType::Color
| ValueType::Links
| ValueType::Parameters
| ValueType::Projection
| ValueType::Scale
| ValueType::Limits
| ValueType::ColormapName
| ValueType::LegendLocation
| ValueType::ColorSpec
| ValueType::DashStyle
| ValueType::MarkerShape
| ValueType::ScatterSize
| ValueType::ScatterColor
| ValueType::Grid
| ValueType::Levels
| ValueType::ContourPlacement
| ValueType::QuiverScale => false,
}
}
fn editor_for(
figure: &Figure,
kind: NodeKind,
node: NodeId,
property: &Property,
value: &Value,
) -> Editor {
if is_composite(property.value_type) {
return Editor::Group;
}
if let Some(reason) = read_only_reason(kind, &property.path) {
return Editor::ReadOnly { reason };
}
match property.value_type {
ValueType::Bool => Editor::Bool,
ValueType::UInt32 => number_editor(&property.path, value, true),
ValueType::Double | ValueType::Float => number_editor(&property.path, value, false),
ValueType::String => Editor::Text,
ValueType::Text => Editor::RichText,
ValueType::Color => Editor::Color,
ValueType::Doubles => Editor::Numbers,
ValueType::Parameters => Editor::Parameters,
ValueType::Links => Editor::ReadOnly {
reason: LINKS_REASON,
},
ValueType::DataId => Editor::Data {
shape: match value {
Value::DataId(id) => figure.data.get(id).map(|array| array.shape.clone()),
_ => None,
},
},
ValueType::Projection
| ValueType::Limits
| ValueType::ColorSpec
| ValueType::ScatterSize
| ValueType::ScatterColor
| ValueType::Grid
| ValueType::Levels
| ValueType::ContourPlacement
| ValueType::QuiverScale
| ValueType::Scale
| ValueType::ColormapName
| ValueType::LegendLocation
| ValueType::MarkerShape
| ValueType::DashStyle
| ValueType::Interpreter
| ValueType::FontSetId => Editor::Choice {
offered: property_choices(figure, node, &property.path),
},
ValueType::FigureSize
| ValueType::TileLayout
| ValueType::Cell
| ValueType::View3d
| ValueType::Axis
| ValueType::Legend
| ValueType::LineStyle
| ValueType::MarkerStyle => Editor::Group,
}
}
fn number_editor(path: &PropertyPath, value: &Value, integer: bool) -> Editor {
let last = path.segments().last().map_or("", String::as_str);
let range = match last {
"elevation_deg" => Some((-90.0, 90.0)),
"rows" | "cols" | "row_span" | "col_span" | "count" => Some((1.0, f64::from(u32::MAX))),
_ if integer => Some((0.0, f64::from(u32::MAX))),
_ => None,
};
let magnitude = match value {
Value::Double(number) if number.is_finite() => number.abs(),
Value::Float(number) if number.is_finite() => f64::from(number.abs()),
_ => 1.0,
};
let speed = match last {
_ if integer => 1.0,
"azimuth_deg" | "elevation_deg" => 0.5,
"r" | "g" | "b" | "a" | "zoom" | "pan_x" | "pan_y" | "head_size" => 0.01,
_ => 0.01 * magnitude.max(1.0),
};
Editor::Number {
speed,
range,
integer,
}
}
#[must_use]
pub fn commit(figure: &Figure, node: NodeId, path: &PropertyPath, value: Value) -> Transaction {
limits_transaction(figure, node, path, &value).unwrap_or(Transaction {
edits: vec![Edit::Set {
node,
path: path.clone(),
value,
}],
})
}
fn limits_transaction(
figure: &Figure,
node: NodeId,
path: &PropertyPath,
value: &Value,
) -> Option<Transaction> {
let segments = path.segments();
let dimension = match segments.first()?.as_str() {
"x" => Dimension::X,
"y" => Dimension::Y,
"z" => Dimension::Z,
_ => return None,
};
if segments.get(1)? != "limits" {
return None;
}
let axes = figure.axes(node)?;
let current = match dimension {
Dimension::X => axes.x.limits,
Dimension::Y => axes.y.limits,
Dimension::Z => axes.z.limits,
};
let limits = match (segments.len(), value) {
(2, Value::Limits(limits)) => *limits,
(3, Value::Double(bound)) => {
let Limits::Manual { min, max } = current else {
return None;
};
match segments[2].as_str() {
"min" => Limits::Manual { min: *bound, max },
"max" => Limits::Manual { min, max: *bound },
_ => return None,
}
}
_ => return None,
};
command::set_limits(figure, node, dimension, limits).ok()
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ParameterKind {
Bool,
Integer,
Number,
#[default]
String,
}
impl ParameterKind {
pub const ALL: [ParameterKind; 4] = [
ParameterKind::Bool,
ParameterKind::Integer,
ParameterKind::Number,
ParameterKind::String,
];
#[must_use]
pub fn label(self) -> &'static str {
match self {
ParameterKind::Bool => "Yes or no",
ParameterKind::Integer => "Whole number",
ParameterKind::Number => "Number",
ParameterKind::String => "Text",
}
}
#[must_use]
pub fn of(parameter: &Parameter) -> Self {
match parameter {
Parameter::Bool(_) => ParameterKind::Bool,
Parameter::Integer(_) => ParameterKind::Integer,
Parameter::Number(_) => ParameterKind::Number,
Parameter::String(_) => ParameterKind::String,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ParameterRow {
pub name: String,
pub kind: ParameterKind,
pub text: String,
pub flag: bool,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParametersDraft {
rows: Vec<ParameterRow>,
}
impl ParametersDraft {
#[must_use]
pub fn of(parameters: &BTreeMap<String, Parameter>) -> Self {
Self {
rows: parameters
.iter()
.map(|(name, parameter)| ParameterRow {
name: name.clone(),
kind: ParameterKind::of(parameter),
text: match parameter {
Parameter::Bool(_) => String::new(),
Parameter::Integer(value) => value.to_string(),
Parameter::Number(value) => value.to_string(),
Parameter::String(value) => value.clone(),
},
flag: matches!(parameter, Parameter::Bool(true)),
})
.collect(),
}
}
#[must_use]
pub fn rows(&self) -> &[ParameterRow] {
&self.rows
}
pub fn rows_mut(&mut self) -> &mut [ParameterRow] {
&mut self.rows
}
pub fn add(&mut self) {
let taken: BTreeSet<&str> = self.rows.iter().map(|row| row.name.as_str()).collect();
let mut name = "parameter".to_owned();
let mut suffix = 1;
while taken.contains(name.as_str()) {
suffix += 1;
name = format!("parameter {suffix}");
}
self.rows.push(ParameterRow {
name,
kind: ParameterKind::String,
text: String::new(),
flag: false,
});
}
pub fn remove(&mut self, index: usize) {
if index < self.rows.len() {
self.rows.remove(index);
}
}
pub fn set_name(&mut self, index: usize, name: &str) {
if let Some(row) = self.rows.get_mut(index) {
row.name = name.to_owned();
}
}
pub fn set_kind(&mut self, index: usize, kind: ParameterKind) {
if let Some(row) = self.rows.get_mut(index) {
row.kind = kind;
}
}
pub fn set_text(&mut self, index: usize, text: &str) {
if let Some(row) = self.rows.get_mut(index) {
row.text = text.to_owned();
}
}
pub fn to_map(&self) -> Result<BTreeMap<String, Parameter>, String> {
let mut map = BTreeMap::new();
for row in &self.rows {
let name = row.name.trim();
if name.is_empty() {
return Err("a parameter has no name".to_owned());
}
let parameter = match row.kind {
ParameterKind::Bool => Parameter::Bool(row.flag),
ParameterKind::String => Parameter::String(row.text.clone()),
ParameterKind::Integer => Parameter::Integer(
row.text
.trim()
.parse::<i64>()
.map_err(|_| format!("{name} is not a whole number: {:?}", row.text))?,
),
ParameterKind::Number => {
let value = row
.text
.trim()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
.ok_or_else(|| format!("{name} is not a finite number: {:?}", row.text))?;
Parameter::Number(value)
}
};
if map.insert(name.to_owned(), parameter).is_some() {
return Err(format!("two parameters are named {name}"));
}
}
Ok(map)
}
}
#[must_use]
pub fn read_only_label(value: &Value) -> String {
match value {
Value::UInt32(number) => number.to_string(),
Value::Double(number) => number.to_string(),
Value::Float(number) => number.to_string(),
Value::String(text) => text.clone(),
Value::Bool(flag) => if *flag { "yes" } else { "no" }.to_owned(),
Value::Links(links) => match links.len() {
0 => "no linked axes".to_owned(),
1 => "1 group of linked axes".to_owned(),
groups => format!("{groups} groups of linked axes"),
},
Value::Unset => "unset".to_owned(),
other => other
.value_type()
.map_or_else(String::new, |value_type| format!("{value_type:?}")),
}
}
#[must_use]
pub fn shape_label(shape: Option<&[usize]>) -> String {
match shape {
None => "no such array".to_owned(),
Some(shape) => {
let lengths: Vec<String> = shape.iter().map(usize::to_string).collect();
format!("[{}]", lengths.join(" × "))
}
}
}
#[must_use]
pub fn data_id(value: &Value) -> Option<DataId> {
match value {
Value::DataId(id) => Some(*id),
_ => None,
}
}