use std::cell::Cell;
use std::collections::BTreeMap;
use ironlab_ir::{
Axis, Cell as IrCell, Choice, Color, FigureSize, Legend, LineStyle, MarkerStyle, NodeId,
Parameter, PropertyPath, Text, TileLayout, Value, ValueType, View3d, choices,
};
use crate::inspector::{
DATA_REASON, Editor, ParameterKind, ParametersDraft, PropertyGroup, PropertyRow, TreeRow,
commit, kind_name, property_groups, read_only_label, shape_label, tree_rows,
};
use crate::interaction::FigureState;
pub const OBJECT_TREE_ID: &str = "ironlab_object_tree";
pub const FOOTER_ID: &str = "ironlab_panel_footer";
const NAME_WIDTH: f32 = 104.0;
const REVERT_WIDTH: f32 = 24.0;
const CONTROL_MIN_WIDTH: f32 = 56.0;
const NAME_MIN_WIDTH: f32 = 32.0;
const TREE_HEIGHT: f32 = 180.0;
const TREE_MIN_HEIGHT: f32 = 64.0;
const INSPECTOR_MIN_HEIGHT: f32 = 120.0;
const FOOTER_PADDING: f32 = 4.0;
const INDENT: f32 = 16.0;
#[derive(Clone, Debug, Default)]
pub struct PropertyPanel {
pub open: bool,
parameters: Option<ParametersDraft>,
parameters_seen: BTreeMap<String, Parameter>,
parameters_problem: Option<String>,
editing: Option<egui::Id>,
touched: bool,
}
impl PropertyPanel {
fn hold(&mut self, state: &mut FigureState, id: egui::Id, active: bool) {
if self.editing == Some(id) {
self.touched = true;
}
if active {
if self.editing != Some(id) {
if self.editing.is_some() {
state.end_edit_step();
}
state.begin_edit_step();
self.editing = Some(id);
self.touched = true;
}
} else if self.editing == Some(id) {
state.end_edit_step();
self.editing = None;
}
}
}
pub fn property_panel(
ui: &mut egui::Ui,
panel: &mut PropertyPanel,
state: &mut FigureState,
) -> bool {
if !panel.open {
return false;
}
panel.touched = false;
let changed = egui::Panel::right("ironlab_property_editor")
.default_size(300.0)
.min_size(220.0)
.show(ui, |ui| {
let mut changed = false;
let foot = footer_height(ui);
let tree_max =
(ui.available_height() - foot - INSPECTOR_MIN_HEIGHT).max(TREE_MIN_HEIGHT);
egui::Panel::top(OBJECT_TREE_ID)
.resizable(true)
.default_size(TREE_HEIGHT)
.size_range(TREE_MIN_HEIGHT.min(tree_max)..=tree_max)
.show(ui, |ui| object_tree(ui, state));
egui::Panel::bottom(FOOTER_ID)
.exact_size(foot)
.show(ui, |ui| {
changed |= footer(ui, state);
});
egui::CentralPanel::default().show(ui, |ui| {
changed |= inspector(ui, panel, state);
});
changed
})
.inner;
if !panel.touched && panel.editing.is_some() {
state.end_edit_step();
panel.editing = None;
}
changed
}
#[must_use]
pub fn revert_all_label(changes: usize) -> String {
match changes {
0 => "Revert all changes".to_owned(),
1 => "Revert all changes (1)".to_owned(),
changes => format!("Revert all changes ({changes})"),
}
}
const REVERT_ALL_HINT: &str = "Discard every change you have made to this figure — axis \
limits, three-dimensional views, hidden plots and every property edited — and show \
the figure as the program that built it defined it. The figure itself is not \
touched. This cannot be undone, unlike Reset view in the toolbar, which discards \
only the limits and three-dimensional views and can be undone.";
const REVERT_ALL_EMPTY_HINT: &str =
"You have made no changes to this figure, so there is nothing to discard.";
fn footer_height(ui: &egui::Ui) -> f32 {
let spacing = ui.spacing();
let button = spacing.interact_size.y + 2.0 * spacing.button_padding.y;
let margin = egui::Frame::side_top_panel(ui.style())
.total_margin()
.sum()
.y;
button + 2.0 * FOOTER_PADDING + margin
}
fn footer(ui: &mut egui::Ui, state: &mut FigureState) -> bool {
let changes = state.change_count();
let clicked = ui
.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_enabled(
changes > 0,
egui::Button::new(revert_all_label(changes)).truncate(),
)
.on_hover_text(REVERT_ALL_HINT)
.on_disabled_hover_text(REVERT_ALL_EMPTY_HINT)
.clicked()
})
.inner;
clicked && state.revert_all()
}
fn object_tree(ui: &mut egui::Ui, state: &mut FigureState) {
ui.add_space(4.0);
ui.label(egui::RichText::new("Objects").strong());
let rows = tree_rows(state.figure());
let selection = state.selection();
let clicked: Cell<Option<NodeId>> = Cell::new(None);
egui::ScrollArea::vertical()
.id_salt("ironlab_object_tree_scroll")
.auto_shrink([false, false])
.show(ui, |ui| {
let mut index = 0;
while index < rows.len() {
index = subtree(ui, &rows, index, selection, &clicked);
}
});
if let Some(node) = clicked.get() {
state.select(Some(node));
}
}
fn subtree(
ui: &mut egui::Ui,
rows: &[TreeRow],
index: usize,
selection: Option<NodeId>,
clicked: &Cell<Option<NodeId>>,
) -> usize {
let row = &rows[index];
let end = rows[index + 1..]
.iter()
.position(|other| other.depth <= row.depth)
.map_or(rows.len(), |offset| index + 1 + offset);
if index + 1 == end {
tree_label(ui, row, selection, clicked);
return index + 1;
}
let id = ui.make_persistent_id(("ironlab_tree", row.node.0));
egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, true)
.show_header(ui, |ui| tree_label(ui, row, selection, clicked))
.body(|ui| {
let mut next = index + 1;
while next < end {
next = subtree(ui, rows, next, selection, clicked);
}
});
end
}
fn tree_label(
ui: &mut egui::Ui,
row: &TreeRow,
selection: Option<NodeId>,
clicked: &Cell<Option<NodeId>>,
) {
let mut text = egui::RichText::new(&row.label);
if row.dimmed {
text = text.color(ui.visuals().weak_text_color());
}
let response = ui.selectable_label(selection == Some(row.node), text);
let response = if row.dimmed {
response.on_hover_text("This plot is hidden.")
} else {
response
};
if response.clicked() {
clicked.set(Some(row.node));
}
}
fn inspector(ui: &mut egui::Ui, panel: &mut PropertyPanel, state: &mut FigureState) -> bool {
let Some(node) = state.selection() else {
ui.label("Select an object to see its properties.");
return false;
};
let Some(kind) = state.figure().node_kind(node) else {
ui.label("The selected object is no longer in the figure.");
return false;
};
ui.add_space(4.0);
ui.label(egui::RichText::new(format!("{} — {node}", kind_name(kind))).strong());
let groups = property_groups(state.figure(), state.overlay(), node);
let mut changed = false;
egui::ScrollArea::vertical()
.id_salt("ironlab_inspector_scroll")
.auto_shrink([false, false])
.show(ui, |ui| {
for group in &groups {
changed |= property_group(ui, panel, state, node, group);
}
});
changed
}
fn property_group(
ui: &mut egui::Ui,
panel: &mut PropertyPanel,
state: &mut FigureState,
node: NodeId,
group: &PropertyGroup,
) -> bool {
ui.add_space(4.0);
if group.rows.first().is_none_or(|row| !row.label.is_empty()) {
ui.label(egui::RichText::new(&group.name).strong());
}
let mut changed = false;
for row in &group.rows {
changed |= property_row(ui, panel, state, node, group, row);
}
changed
}
fn property_row(
ui: &mut egui::Ui,
panel: &mut PropertyPanel,
state: &mut FigureState,
node: NodeId,
group: &PropertyGroup,
row: &PropertyRow,
) -> bool {
let name = if row.label.is_empty() {
group.name.clone()
} else {
row.label.clone()
};
if matches!(row.editor, Editor::Parameters) {
let mut changed = ui
.horizontal(|ui| {
let room = ui.available_width();
name_column(ui, row, &name, 0.0);
column(
ui,
control_width(ui, room),
egui::Layout::right_to_left(egui::Align::Center),
|_| (),
);
revert_column(ui, state, node, row, &name)
})
.inner;
changed |= parameters_editor(ui, panel, state);
return changed;
}
ui.horizontal(|ui| {
let indent = INDENT * row.depth as f32;
let room = ui.available_width();
name_column(ui, row, &name, indent);
let mut changed = column(
ui,
control_width(ui, room),
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if row.value == Value::Unset {
return unset_control(ui, state, node, row, &name);
}
match &row.editor {
Editor::Bool => bool_control(ui, panel, state, node, row, &name),
Editor::Number {
speed,
range,
integer,
} => number_control(ui, panel, state, node, row, *speed, *range, *integer),
Editor::Choice { offered } => choice_control(ui, state, node, row, offered),
Editor::Color => color_control(ui, panel, state, node, row),
Editor::Text | Editor::RichText => text_control(ui, panel, state, node, row),
Editor::Numbers => numbers_control(ui, panel, state, node, row),
Editor::Data { shape } => {
let text = data_label(row, shape.as_deref());
read_only_control(ui, row, text, DATA_REASON);
false
}
Editor::ReadOnly { reason } => {
read_only_control(ui, row, read_only_label(&row.value), reason);
false
}
Editor::Group | Editor::Parameters => false,
}
},
);
changed |= revert_column(ui, state, node, row, &name);
changed
})
.inner
}
fn control_width(ui: &egui::Ui, room: f32) -> f32 {
let spacing = 2.0 * ui.spacing().item_spacing.x;
(room - NAME_WIDTH - REVERT_WIDTH - spacing).max(CONTROL_MIN_WIDTH)
}
fn column<R>(
ui: &mut egui::Ui,
width: f32,
layout: egui::Layout,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let start = ui.cursor().left();
let height = ui.spacing().interact_size.y;
let inner = ui
.allocate_ui_with_layout(egui::vec2(width, height), layout, add)
.inner;
let short = start + width + ui.spacing().item_spacing.x - ui.cursor().left();
if short > 0.0 {
ui.add_space(short);
}
inner
}
fn name_column(ui: &mut egui::Ui, row: &PropertyRow, name: &str, indent: f32) {
column(
ui,
NAME_WIDTH,
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.add_space(indent.min(NAME_WIDTH - NAME_MIN_WIDTH));
let mut text = egui::RichText::new(name);
if row.overridden {
text = text.strong();
}
ui.add(egui::Label::new(text).truncate())
.on_hover_text(row.docs);
},
);
}
fn revert_column(
ui: &mut egui::Ui,
state: &mut FigureState,
node: NodeId,
row: &PropertyRow,
name: &str,
) -> bool {
column(
ui,
REVERT_WIDTH,
egui::Layout::right_to_left(egui::Align::Center),
|ui| row.overridden && revert_button(ui, state, node, &row.path, name),
)
}
fn revert_button(
ui: &mut egui::Ui,
state: &mut FigureState,
node: NodeId,
path: &PropertyPath,
name: &str,
) -> bool {
let label = format!("Revert {name}");
let enabled = ui.is_enabled();
let response = ui.small_button(crate::style::REVERT);
response.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label.clone())
});
let response = response.on_hover_text(format!(
"Take back your change to {name} and show the figure's own value again."
));
response.clicked() && state.revert(node, path)
}
fn set(state: &mut FigureState, node: NodeId, path: &PropertyPath, value: Value) -> bool {
let transaction = commit(state.figure(), node, path, value);
state.try_record(&transaction)
}
fn bool_control(
ui: &mut egui::Ui,
panel: &mut PropertyPanel,
state: &mut FigureState,
node: NodeId,
row: &PropertyRow,
name: &str,
) -> bool {
let Value::Bool(current) = row.value else {
return false;
};
let mut flag = current;
let enabled = ui.is_enabled();
let response = ui.checkbox(&mut flag, "");
let label = name.to_owned();
response.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled, flag, label.clone())
});
let response = response.on_hover_text(row.docs);
panel.hold(state, response.id, response.has_focus());
response.changed() && set(state, node, &row.path, Value::Bool(flag))
}
#[allow(clippy::too_many_arguments)]
fn number_control(
ui: &mut egui::Ui,
panel: &mut PropertyPanel,
state: &mut FigureState,
node: NodeId,
row: &PropertyRow,
speed: f64,
range: Option<(f64, f64)>,
integer: bool,
) -> bool {
let current = match &row.value {
Value::Double(number) => *number,
Value::Float(number) => f64::from(*number),
Value::UInt32(number) => f64::from(*number),
_ => return false,
};
let mut number = current;
let mut widget = egui::DragValue::new(&mut number).speed(speed);
if let Some((low, high)) = range {
widget = widget.range(low..=high);
}
let response = ui.add(widget).on_hover_text(row.docs);
panel.hold(
state,
response.id,
response.dragged() || response.has_focus(),
);
if !response.changed() {
return false;
}
let value = match &row.value {
Value::Float(_) => Value::Float(number as f32),
Value::UInt32(_) => Value::UInt32(number.clamp(0.0, f64::from(u32::MAX)) as u32),
_ if integer => Value::Double(number.round()),
_ => Value::Double(number),
};
set(state, node, &row.path, value)
}
fn choice_control(
ui: &mut egui::Ui,
state: &mut FigureState,
node: NodeId,
row: &PropertyRow,
offered: &[Choice],
) -> bool {
let current = offered
.iter()
.find(|choice| choice.matches(&row.value))
.map_or("…", |choice| choice.label);
let mut chosen: Option<Value> = None;
egui::ComboBox::from_id_salt(("ironlab_choice", row.path.to_string()))
.selected_text(current)
.show_ui(ui, |ui| {
for choice in offered {
if let Some(reason) = choice.unavailable {
ui.add_enabled(false, egui::Button::selectable(false, choice.label))
.on_disabled_hover_text(reason);
} else if ui
.selectable_label(choice.label == current, choice.label)
.clicked()
&& choice.label != current
{
chosen = Some(choice.value.clone());
}
}
});
match chosen {
Some(value) => set(state, node, &row.path, value),
None => false,
}
}
fn color_control(
ui: &mut egui::Ui,
panel: &mut PropertyPanel,
state: &mut FigureState,
node: NodeId,
row: &PropertyRow,
) -> bool {
let Value::Color(current) = row.value else {
return false;
};
let mut rgba = to_color32(current);
let response = egui::color_picker::color_edit_button_srgba(
ui,
&mut rgba,
egui::color_picker::Alpha::OnlyBlend,
)
.on_hover_text(row.docs);
panel.hold(state, response.id, response.has_focus());
let chosen = from_color32(rgba);
chosen != current && set(state, node, &row.path, Value::Color(chosen))
}
fn text_control(
ui: &mut egui::Ui,
panel: &mut PropertyPanel,
state: &mut FigureState,
node: NodeId,
row: &PropertyRow,
) -> bool {
let (mut content, interpreter) = match &row.value {
Value::Text(text) => (text.content.clone(), Some(text.interpreter)),
Value::String(text) => (text.clone(), None),
_ => return false,
};
let mut changed = false;
let mut chosen = None;
if let Some(interpreter) = interpreter {
let offered = choices(ValueType::Interpreter, None);
let current = offered
.iter()
.find(|choice| choice.matches(&Value::Interpreter(interpreter)))
.map_or("…", |choice| choice.label);
egui::ComboBox::from_id_salt(("ironlab_interpreter", row.path.to_string()))
.selected_text(current)
.width(64.0)
.show_ui(ui, |ui| {
for choice in &offered {
if ui
.selectable_label(choice.label == current, choice.label)
.clicked()
&& let Value::Interpreter(picked) = choice.value
{
chosen = Some(picked);
}
}
});
}
let response = ui
.add(
egui::TextEdit::singleline(&mut content)
.desired_width(ui.available_width())
.hint_text("empty"),
)
.on_hover_text(row.docs);
panel.hold(state, response.id, response.has_focus());
if response.changed() {
let value = match interpreter {
Some(interpreter) => Value::Text(Text {
content: content.clone(),
interpreter,
}),
None => Value::String(content.clone()),
};
changed |= set(state, node, &row.path, value);
}
if let Some(picked) = chosen
&& Some(picked) != interpreter
{
changed |= set(
state,
node,
&row.path,
Value::Text(Text {
content,
interpreter: picked,
}),
);
}
changed
}
fn numbers_control(
ui: &mut egui::Ui,
panel: &mut PropertyPanel,
state: &mut FigureState,
node: NodeId,
row: &PropertyRow,
) -> bool {
let Value::Doubles(current) = &row.value else {
return false;
};
let mut text = current
.iter()
.map(f64::to_string)
.collect::<Vec<String>>()
.join(", ");
let response = ui
.add(egui::TextEdit::singleline(&mut text).desired_width(ui.available_width()))
.on_hover_text(format!("{} Separate the numbers with commas.", row.docs));
panel.hold(state, response.id, response.has_focus());
if !response.changed() {
return false;
}
let parsed: Result<Vec<f64>, _> = text
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::parse::<f64>)
.collect();
match parsed {
Ok(numbers) if numbers != *current => set(state, node, &row.path, Value::Doubles(numbers)),
_ => false,
}
}
fn read_only_control(ui: &mut egui::Ui, row: &PropertyRow, text: String, reason: &str) {
ui.add(egui::Label::new(egui::RichText::new(text).weak()).truncate())
.on_hover_text(format!("{} {reason}", row.docs));
}
fn data_label(row: &PropertyRow, shape: Option<&[usize]>) -> String {
match &row.value {
Value::DataId(id) => format!("{id} {}", shape_label(shape)),
_ => shape_label(shape),
}
}
fn unset_control(
ui: &mut egui::Ui,
state: &mut FigureState,
node: NodeId,
row: &PropertyRow,
name: &str,
) -> bool {
let changed = match default_value(row.value_type) {
None => false,
Some(value) => {
ui.small_button("Set")
.on_hover_text(format!("Give {name} a value."))
.clicked()
&& set(state, node, &row.path, value)
}
};
ui.label(egui::RichText::new("unset").weak());
changed
}
fn default_value(value_type: ValueType) -> Option<Value> {
if let Some(choice) = choices(value_type, None).into_iter().next() {
return Some(choice.value);
}
match value_type {
ValueType::Bool => Some(Value::Bool(false)),
ValueType::UInt32 => Some(Value::UInt32(0)),
ValueType::Double => Some(Value::Double(0.0)),
ValueType::Float => Some(Value::Float(0.0)),
ValueType::String => Some(Value::String(String::new())),
ValueType::Doubles => Some(Value::Doubles(Vec::new())),
ValueType::Text => Some(Value::Text(Text::default())),
ValueType::Color => Some(Value::Color(Color::BLACK)),
ValueType::FigureSize => Some(Value::FigureSize(FigureSize::default())),
ValueType::TileLayout => Some(Value::TileLayout(TileLayout::default())),
ValueType::Links => Some(Value::Links(Vec::new())),
ValueType::Parameters => Some(Value::Parameters(BTreeMap::new())),
ValueType::Cell => Some(Value::Cell(IrCell::default())),
ValueType::View3d => Some(Value::View3d(View3d::default())),
ValueType::Axis => Some(Value::Axis(Axis::default())),
ValueType::Legend => Some(Value::Legend(Legend::default())),
ValueType::LineStyle => Some(Value::LineStyle(LineStyle::default())),
ValueType::MarkerStyle => Some(Value::MarkerStyle(MarkerStyle::default())),
ValueType::DataId => 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 => None,
}
}
fn to_color32(color: Color) -> egui::Color32 {
let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
egui::Color32::from_rgba_unmultiplied(
channel(color.r),
channel(color.g),
channel(color.b),
channel(color.a),
)
}
fn from_color32(color: egui::Color32) -> Color {
let channel = |value: u8| f32::from(value) / 255.0;
let [r, g, b, a] = color.to_srgba_unmultiplied();
Color::rgba(channel(r), channel(g), channel(b), channel(a))
}
fn parameters_path() -> PropertyPath {
PropertyPath::new(["parameters"]).expect("parameters names a property")
}
fn parameters_editor(
ui: &mut egui::Ui,
panel: &mut PropertyPanel,
state: &mut FigureState,
) -> bool {
let node = state.figure().id;
let current = state.figure().parameters.clone();
if panel.parameters.is_none() || panel.parameters_seen != current {
panel.parameters = Some(ParametersDraft::of(¤t));
panel.parameters_seen = current.clone();
panel.parameters_problem = None;
}
let mut draft = panel.parameters.take().unwrap_or_default();
let mut remove = None;
let mut active: Option<egui::Id> = None;
ui.vertical(|ui| {
egui::Grid::new("ironlab_parameters")
.num_columns(4)
.spacing([6.0, 4.0])
.show(ui, |ui| {
for (index, row) in draft.rows_mut().iter_mut().enumerate() {
let name = ui.add(
egui::TextEdit::singleline(&mut row.name)
.desired_width(80.0)
.hint_text("name"),
);
if name.has_focus() {
active = Some(name.id);
}
egui::ComboBox::from_id_salt(("ironlab_parameter_kind", index))
.selected_text(row.kind.label())
.width(96.0)
.show_ui(ui, |ui| {
for kind in ParameterKind::ALL {
ui.selectable_value(&mut row.kind, kind, kind.label());
}
});
if row.kind == ParameterKind::Bool {
ui.checkbox(&mut row.flag, "");
} else {
let value = ui.add(
egui::TextEdit::singleline(&mut row.text)
.desired_width(80.0)
.hint_text("value"),
);
if value.has_focus() {
active = Some(value.id);
}
}
if ui
.small_button("Remove")
.on_hover_text("Remove this parameter.")
.clicked()
{
remove = Some(index);
}
ui.end_row();
}
});
if ui
.button("Add parameter")
.on_hover_text("Add a named value that describes the figure.")
.clicked()
{
draft.add();
}
if let Some(problem) = &panel.parameters_problem {
ui.label(egui::RichText::new(problem).color(ui.visuals().error_fg_color));
}
});
if let Some(index) = remove {
draft.remove(index);
}
if let Some(id) = active {
panel.hold(state, id, true);
}
let mut changed = false;
match draft.to_map() {
Ok(map) => {
panel.parameters_problem = None;
if map != current {
changed = set(
state,
node,
¶meters_path(),
Value::Parameters(map.clone()),
);
panel.parameters_seen = state.figure().parameters.clone();
}
}
Err(problem) => panel.parameters_problem = Some(problem),
}
panel.parameters = Some(draft);
changed
}