325 lines
10 KiB
Rust
325 lines
10 KiB
Rust
use std::{collections::BTreeMap, string::String};
|
|
|
|
use crate::*;
|
|
|
|
use super::items::PlotItem;
|
|
|
|
/// Where to place the plot legend.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Corner {
|
|
LeftTop,
|
|
RightTop,
|
|
LeftBottom,
|
|
RightBottom,
|
|
}
|
|
|
|
impl Corner {
|
|
pub fn all() -> impl Iterator<Item = Self> {
|
|
[
|
|
Self::LeftTop,
|
|
Self::RightTop,
|
|
Self::LeftBottom,
|
|
Self::RightBottom,
|
|
]
|
|
.iter()
|
|
.copied()
|
|
}
|
|
}
|
|
|
|
/// The configuration for a plot legend.
|
|
#[derive(Clone, PartialEq)]
|
|
pub struct Legend {
|
|
pub text_style: TextStyle,
|
|
pub background_alpha: f32,
|
|
pub position: Corner,
|
|
|
|
/// Used for overriding the `hidden_items` set in [`LegendWidget`].
|
|
hidden_items: Option<ahash::HashSet<String>>,
|
|
}
|
|
|
|
impl Default for Legend {
|
|
fn default() -> Self {
|
|
Self {
|
|
text_style: TextStyle::Body,
|
|
background_alpha: 0.75,
|
|
position: Corner::RightTop,
|
|
|
|
hidden_items: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Legend {
|
|
/// Which text style to use for the legend. Default: `TextStyle::Body`.
|
|
#[inline]
|
|
pub fn text_style(mut self, style: TextStyle) -> Self {
|
|
self.text_style = style;
|
|
self
|
|
}
|
|
|
|
/// The alpha of the legend background. Default: `0.75`.
|
|
#[inline]
|
|
pub fn background_alpha(mut self, alpha: f32) -> Self {
|
|
self.background_alpha = alpha;
|
|
self
|
|
}
|
|
|
|
/// In which corner to place the legend. Default: `Corner::RightTop`.
|
|
#[inline]
|
|
pub fn position(mut self, corner: Corner) -> Self {
|
|
self.position = corner;
|
|
self
|
|
}
|
|
|
|
/// Specifies hidden items in the legend configuration to override the existing ones. This
|
|
/// allows the legend traces' visibility to be controlled from the application code.
|
|
#[inline]
|
|
pub fn hidden_items<I>(mut self, hidden_items: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = String>,
|
|
{
|
|
self.hidden_items = Some(hidden_items.into_iter().collect());
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct LegendEntry {
|
|
color: Color32,
|
|
checked: bool,
|
|
hovered: bool,
|
|
}
|
|
|
|
impl LegendEntry {
|
|
fn new(color: Color32, checked: bool) -> Self {
|
|
Self {
|
|
color,
|
|
checked,
|
|
hovered: false,
|
|
}
|
|
}
|
|
|
|
fn ui(&self, ui: &mut Ui, text: String, text_style: &TextStyle) -> Response {
|
|
let Self {
|
|
color,
|
|
checked,
|
|
hovered: _,
|
|
} = self;
|
|
|
|
let font_id = text_style.resolve(ui.style());
|
|
|
|
let galley = ui.fonts(|f| f.layout_delayed_color(text, font_id, f32::INFINITY));
|
|
|
|
let icon_size = galley.size().y;
|
|
let icon_spacing = icon_size / 5.0;
|
|
let total_extra = vec2(icon_size + icon_spacing, 0.0);
|
|
|
|
let desired_size = total_extra + galley.size();
|
|
let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
|
|
|
|
response.widget_info(|| {
|
|
WidgetInfo::selected(
|
|
WidgetType::Checkbox,
|
|
ui.is_enabled(),
|
|
*checked,
|
|
galley.text(),
|
|
)
|
|
});
|
|
|
|
let visuals = ui.style().interact(&response);
|
|
let label_on_the_left = ui.layout().horizontal_placement() == Align::RIGHT;
|
|
|
|
let icon_position_x = if label_on_the_left {
|
|
rect.right() - icon_size / 2.0
|
|
} else {
|
|
rect.left() + icon_size / 2.0
|
|
};
|
|
let icon_position = pos2(icon_position_x, rect.center().y);
|
|
let icon_rect = Rect::from_center_size(icon_position, vec2(icon_size, icon_size));
|
|
|
|
let painter = ui.painter();
|
|
|
|
painter.add(epaint::CircleShape {
|
|
center: icon_rect.center(),
|
|
radius: icon_size * 0.5,
|
|
fill: visuals.bg_fill,
|
|
stroke: visuals.bg_stroke,
|
|
});
|
|
|
|
if *checked {
|
|
let fill = if *color == Color32::TRANSPARENT {
|
|
ui.visuals().noninteractive().fg_stroke.color
|
|
} else {
|
|
*color
|
|
};
|
|
painter.add(epaint::Shape::circle_filled(
|
|
icon_rect.center(),
|
|
icon_size * 0.4,
|
|
fill,
|
|
));
|
|
}
|
|
|
|
let text_position_x = if label_on_the_left {
|
|
rect.right() - icon_size - icon_spacing - galley.size().x
|
|
} else {
|
|
rect.left() + icon_size + icon_spacing
|
|
};
|
|
|
|
let text_position = pos2(text_position_x, rect.center().y - 0.5 * galley.size().y);
|
|
painter.galley(text_position, galley, visuals.text_color());
|
|
|
|
response
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(super) struct LegendWidget {
|
|
rect: Rect,
|
|
entries: BTreeMap<String, LegendEntry>,
|
|
config: Legend,
|
|
}
|
|
|
|
impl LegendWidget {
|
|
/// Create a new legend from items, the names of items that are hidden and the style of the
|
|
/// text. Returns `None` if the legend has no entries.
|
|
pub(super) fn try_new(
|
|
rect: Rect,
|
|
config: Legend,
|
|
items: &[Box<dyn PlotItem>],
|
|
hidden_items: &ahash::HashSet<String>, // Existing hidden items in the plot memory.
|
|
) -> Option<Self> {
|
|
// If `config.hidden_items` is not `None`, it is used.
|
|
let hidden_items = config.hidden_items.as_ref().unwrap_or(hidden_items);
|
|
|
|
// Collect the legend entries. If multiple items have the same name, they share a
|
|
// checkbox. If their colors don't match, we pick a neutral color for the checkbox.
|
|
let mut entries: BTreeMap<String, LegendEntry> = BTreeMap::new();
|
|
items
|
|
.iter()
|
|
.filter(|item| !item.name().is_empty())
|
|
.for_each(|item| {
|
|
entries
|
|
.entry(item.name().to_owned())
|
|
.and_modify(|entry| {
|
|
if entry.color != item.color() {
|
|
// Multiple items with different colors
|
|
entry.color = Color32::TRANSPARENT;
|
|
}
|
|
})
|
|
.or_insert_with(|| {
|
|
let color = item.color();
|
|
let checked = !hidden_items.contains(item.name());
|
|
LegendEntry::new(color, checked)
|
|
});
|
|
});
|
|
(!entries.is_empty()).then_some(Self {
|
|
rect,
|
|
entries,
|
|
config,
|
|
})
|
|
}
|
|
|
|
// Get the names of the hidden items.
|
|
pub fn hidden_items(&self) -> ahash::HashSet<String> {
|
|
self.entries
|
|
.iter()
|
|
.filter(|(_, entry)| !entry.checked)
|
|
.map(|(name, _)| name.clone())
|
|
.collect()
|
|
}
|
|
|
|
// Get the name of the hovered items.
|
|
pub fn hovered_item_name(&self) -> Option<String> {
|
|
self.entries
|
|
.iter()
|
|
.find(|(_, entry)| entry.hovered)
|
|
.map(|(name, _)| name.to_string())
|
|
}
|
|
}
|
|
|
|
impl Widget for &mut LegendWidget {
|
|
fn ui(self, ui: &mut Ui) -> Response {
|
|
let LegendWidget {
|
|
rect,
|
|
entries,
|
|
config,
|
|
} = self;
|
|
|
|
let main_dir = match config.position {
|
|
Corner::LeftTop | Corner::RightTop => Direction::TopDown,
|
|
Corner::LeftBottom | Corner::RightBottom => Direction::BottomUp,
|
|
};
|
|
let cross_align = match config.position {
|
|
Corner::LeftTop | Corner::LeftBottom => Align::LEFT,
|
|
Corner::RightTop | Corner::RightBottom => Align::RIGHT,
|
|
};
|
|
let layout = Layout::from_main_dir_and_cross_align(main_dir, cross_align);
|
|
let legend_pad = 4.0;
|
|
let legend_rect = rect.shrink(legend_pad);
|
|
let mut legend_ui = ui.child_ui(legend_rect, layout, None);
|
|
legend_ui
|
|
.scope(|ui| {
|
|
let background_frame = Frame {
|
|
inner_margin: vec2(8.0, 4.0).into(),
|
|
rounding: ui.style().visuals.window_rounding,
|
|
shadow: epaint::Shadow::NONE,
|
|
fill: ui.style().visuals.extreme_bg_color,
|
|
stroke: ui.style().visuals.window_stroke(),
|
|
..Default::default()
|
|
}
|
|
.multiply_with_opacity(config.background_alpha);
|
|
background_frame
|
|
.show(ui, |ui| {
|
|
let mut focus_on_item = None;
|
|
|
|
let response_union = entries
|
|
.iter_mut()
|
|
.map(|(name, entry)| {
|
|
let response = entry.ui(ui, name.clone(), &config.text_style);
|
|
|
|
// Handle interactions. Alt-clicking must be deferred to end of loop
|
|
// since it may affect all entries.
|
|
handle_interaction_on_legend_item(&response, entry);
|
|
if response.clicked() && ui.input(|r| r.modifiers.alt) {
|
|
focus_on_item = Some(name.clone());
|
|
}
|
|
|
|
response
|
|
})
|
|
.reduce(|r1, r2| r1.union(r2))
|
|
.unwrap();
|
|
|
|
if let Some(focus_on_item) = focus_on_item {
|
|
handle_focus_on_legend_item(&focus_on_item, entries);
|
|
}
|
|
|
|
response_union
|
|
})
|
|
.inner
|
|
})
|
|
.inner
|
|
}
|
|
}
|
|
|
|
/// Handle per-entry interactions.
|
|
fn handle_interaction_on_legend_item(response: &Response, entry: &mut LegendEntry) {
|
|
entry.checked ^= response.clicked_by(PointerButton::Primary);
|
|
entry.hovered = response.hovered();
|
|
}
|
|
|
|
/// Handle alt-click interaction (which may affect all entries).
|
|
fn handle_focus_on_legend_item(
|
|
clicked_entry_name: &str,
|
|
entries: &mut BTreeMap<String, LegendEntry>,
|
|
) {
|
|
// if all other items are already hidden, we show everything
|
|
let is_focus_item_only_visible = entries
|
|
.iter()
|
|
.all(|(name, entry)| !entry.checked || (clicked_entry_name == name));
|
|
|
|
// either show everything or show only the focus item
|
|
for (name, entry) in entries.iter_mut() {
|
|
entry.checked = is_focus_item_only_visible || clicked_entry_name == name;
|
|
}
|
|
}
|