use std::collections::HashSet;
use crate::component::Component;
use crate::component::context::{EventContext, RenderContext};
use crate::input::Event;
mod edge_routing;
mod graph;
pub mod layout;
mod navigation;
mod render;
mod search;
mod state;
pub mod types;
mod viewport;
pub use layout::{EdgePath, LayoutResult, NodePosition, PathSegment};
pub use types::{
DiagramCluster, DiagramEdge, DiagramNode, EdgeStyle, LayoutMode, NodeShape, NodeStatus,
Orientation, RenderMode,
};
pub use viewport::{BoundingBox, Viewport2D};
#[derive(Clone, Debug, Default)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct DiagramState {
pub(crate) nodes: Vec<DiagramNode>,
pub(crate) edges: Vec<DiagramEdge>,
pub(crate) clusters: Vec<DiagramCluster>,
pub(crate) selected: Option<usize>,
pub(crate) selection_history: Vec<usize>,
pub(crate) layout_mode: LayoutMode,
pub(crate) orientation: Orientation,
#[cfg_attr(feature = "serialization", serde(skip))]
pub(crate) cached_layout: Option<LayoutResult>,
pub(crate) layout_dirty: bool,
pub(crate) viewport: Viewport2D,
pub(crate) title: Option<String>,
pub(crate) show_edge_labels: bool,
pub(crate) render_mode: RenderMode,
pub(crate) show_minimap: bool,
pub(crate) expanded_nodes: HashSet<String>,
pub(crate) collapsed_clusters: HashSet<String>,
#[cfg_attr(feature = "serialization", serde(skip))]
pub(crate) follow_targets: Option<Vec<String>>,
#[cfg_attr(feature = "serialization", serde(skip))]
pub(crate) search: search::SearchState,
}
impl PartialEq for DiagramState {
fn eq(&self, other: &Self) -> bool {
self.nodes == other.nodes
&& self.edges == other.edges
&& self.clusters == other.clusters
&& self.selected == other.selected
&& self.layout_mode == other.layout_mode
&& self.orientation == other.orientation
&& self.viewport == other.viewport
&& self.title == other.title
&& self.show_edge_labels == other.show_edge_labels
&& self.render_mode == other.render_mode
&& self.show_minimap == other.show_minimap
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum DiagramMessage {
SetNodes(Vec<DiagramNode>),
SetEdges(Vec<DiagramEdge>),
AddNode(DiagramNode),
AddEdge(DiagramEdge),
RemoveNode(String),
RemoveEdge(String, String),
AddCluster(DiagramCluster),
RemoveCluster(String),
UpdateNodeStatus {
id: String,
status: NodeStatus,
},
Clear,
SelectNext,
SelectPrev,
SelectNode(String),
SelectUp,
SelectDown,
SelectLeft,
SelectRight,
FollowEdge,
FollowEdgeChoice(usize),
GoBack,
CancelFollow,
Pan {
dx: f64,
dy: f64,
},
ZoomIn,
ZoomOut,
FitToView,
ToggleMinimap,
ToggleNodeExpand,
ToggleCluster,
SetLayoutMode(LayoutMode),
SetOrientation(Orientation),
SetRenderMode(RenderMode),
StartSearch,
SearchInput(char),
SearchBackspace,
SearchNext,
SearchPrev,
ConfirmSearch,
CancelSearch,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DiagramOutput {
NodeSelected(String),
NodeDeselected,
EdgeFollowed {
from: String,
to: String,
},
EdgeChoiceRequired {
from: String,
targets: Vec<String>,
},
StatusChanged {
id: String,
old: NodeStatus,
new_status: NodeStatus,
},
ClusterToggled {
id: String,
collapsed: bool,
},
SearchMatched {
id: String,
total_matches: usize,
},
}
pub struct Diagram;
impl Component for Diagram {
type State = DiagramState;
type Message = DiagramMessage;
type Output = DiagramOutput;
fn init() -> Self::State {
DiagramState::new()
}
fn handle_event(
state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
use crate::input::Key;
match event {
Event::Key(key) if state.search.active => {
match key.code {
Key::Esc => Some(DiagramMessage::CancelSearch),
Key::Enter => Some(DiagramMessage::ConfirmSearch),
Key::Backspace => Some(DiagramMessage::SearchBackspace),
Key::Char('n') if key.modifiers.shift() => Some(DiagramMessage::SearchPrev),
Key::Char('n') => Some(DiagramMessage::SearchNext),
Key::Char(c) => Some(DiagramMessage::SearchInput(c)),
_ => None,
}
}
Event::Key(key) => match key.code {
Key::Down | Key::Char('j') => Some(DiagramMessage::SelectDown),
Key::Up | Key::Char('k') => Some(DiagramMessage::SelectUp),
Key::Left | Key::Char('h') => Some(DiagramMessage::SelectLeft),
Key::Right | Key::Char('l') => Some(DiagramMessage::SelectRight),
Key::Tab if key.modifiers.shift() => Some(DiagramMessage::SelectPrev),
Key::Tab => Some(DiagramMessage::SelectNext),
Key::Enter => Some(DiagramMessage::FollowEdge),
Key::Backspace => Some(DiagramMessage::GoBack),
Key::Char('H') => Some(DiagramMessage::Pan { dx: -1.0, dy: 0.0 }),
Key::Char('J') => Some(DiagramMessage::Pan { dx: 0.0, dy: 1.0 }),
Key::Char('K') => Some(DiagramMessage::Pan { dx: 0.0, dy: -1.0 }),
Key::Char('L') => Some(DiagramMessage::Pan { dx: 1.0, dy: 0.0 }),
Key::Char('+') | Key::Char('=') => Some(DiagramMessage::ZoomIn),
Key::Char('-') => Some(DiagramMessage::ZoomOut),
Key::Char('0') => Some(DiagramMessage::FitToView),
Key::Char('m') => Some(DiagramMessage::ToggleMinimap),
Key::Char(' ') => Some(DiagramMessage::ToggleNodeExpand),
Key::Char('c') => Some(DiagramMessage::ToggleCluster),
Key::Char('/') => Some(DiagramMessage::StartSearch),
Key::Char(c @ '1'..='9') if state.follow_targets.is_some() => {
let idx = (c as usize) - ('1' as usize);
Some(DiagramMessage::FollowEdgeChoice(idx))
}
Key::Esc if state.follow_targets.is_some() => Some(DiagramMessage::CancelFollow),
_ => None,
},
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
DiagramMessage::SetNodes(nodes) => {
state.nodes = nodes;
state.selected = None;
state.layout_dirty = true;
None
}
DiagramMessage::SetEdges(edges) => {
state.edges = edges;
state.layout_dirty = true;
None
}
DiagramMessage::AddNode(node) => {
state.add_node(node);
None
}
DiagramMessage::AddEdge(edge) => {
state.add_edge(edge);
None
}
DiagramMessage::RemoveNode(id) => {
state.remove_node(&id);
None
}
DiagramMessage::RemoveEdge(from, to) => {
state.remove_edge(&from, &to);
None
}
DiagramMessage::AddCluster(cluster) => {
state.clusters.push(cluster);
state.layout_dirty = true;
None
}
DiagramMessage::RemoveCluster(id) => {
state.clusters.retain(|c| c.id() != id);
state.layout_dirty = true;
None
}
DiagramMessage::UpdateNodeStatus { id, status } => {
if let Some(old) = state.update_node_status(&id, status.clone()) {
if old != status {
return Some(DiagramOutput::StatusChanged {
id,
old,
new_status: status,
});
}
}
None
}
DiagramMessage::Clear => {
state.clear();
None
}
DiagramMessage::SelectNext => {
if state.select_next() {
state
.selected_node()
.map(|n| DiagramOutput::NodeSelected(n.id().to_string()))
} else {
None
}
}
DiagramMessage::SelectPrev => {
if state.select_prev() {
state
.selected_node()
.map(|n| DiagramOutput::NodeSelected(n.id().to_string()))
} else {
None
}
}
DiagramMessage::SelectNode(id) => {
if let Some(idx) = state.nodes.iter().position(|n| n.id() == id) {
state.selected = Some(idx);
Some(DiagramOutput::NodeSelected(id))
} else {
None
}
}
DiagramMessage::SelectUp => {
if state.select_direction(navigation::Direction::Up) {
state
.selected_node()
.map(|n| DiagramOutput::NodeSelected(n.id().to_string()))
} else {
None
}
}
DiagramMessage::SelectDown => {
if state.select_direction(navigation::Direction::Down) {
state
.selected_node()
.map(|n| DiagramOutput::NodeSelected(n.id().to_string()))
} else {
None
}
}
DiagramMessage::SelectLeft => {
if state.select_direction(navigation::Direction::Left) {
state
.selected_node()
.map(|n| DiagramOutput::NodeSelected(n.id().to_string()))
} else {
None
}
}
DiagramMessage::SelectRight => {
if state.select_direction(navigation::Direction::Right) {
state
.selected_node()
.map(|n| DiagramOutput::NodeSelected(n.id().to_string()))
} else {
None
}
}
DiagramMessage::FollowEdge => state.follow_edge(),
DiagramMessage::FollowEdgeChoice(idx) => state.follow_edge_choice(idx),
DiagramMessage::GoBack => {
if state.go_back() {
state
.selected_node()
.map(|n| DiagramOutput::NodeSelected(n.id().to_string()))
} else {
None
}
}
DiagramMessage::CancelFollow => {
state.follow_targets = None;
None
}
DiagramMessage::Pan { dx, dy } => {
state.viewport.pan_step(dx, dy);
None
}
DiagramMessage::ZoomIn => {
state.viewport.zoom_in();
None
}
DiagramMessage::ZoomOut => {
state.viewport.zoom_out();
None
}
DiagramMessage::FitToView => {
state.viewport.fit_to_content();
None
}
DiagramMessage::ToggleMinimap => {
state.show_minimap = !state.show_minimap;
None
}
DiagramMessage::ToggleNodeExpand => {
if let Some(node) = state.selected_node() {
let id = node.id().to_string();
if state.expanded_nodes.contains(&id) {
state.expanded_nodes.remove(&id);
} else {
state.expanded_nodes.insert(id);
}
}
None
}
DiagramMessage::ToggleCluster => {
if let Some(node) = state.selected_node() {
if let Some(cluster_id) = node.cluster_id() {
let cluster_id = cluster_id.to_string();
let collapsed = if state.collapsed_clusters.contains(&cluster_id) {
state.collapsed_clusters.remove(&cluster_id);
false
} else {
state.collapsed_clusters.insert(cluster_id.clone());
true
};
return Some(DiagramOutput::ClusterToggled {
id: cluster_id,
collapsed,
});
}
}
None
}
DiagramMessage::SetLayoutMode(mode) => {
state.set_layout_mode(mode);
None
}
DiagramMessage::SetOrientation(orientation) => {
state.set_orientation(orientation);
None
}
DiagramMessage::SetRenderMode(mode) => {
state.render_mode = mode;
None
}
DiagramMessage::StartSearch => {
state.search.start();
None
}
DiagramMessage::SearchInput(ch) => {
state.search.input(ch, &state.nodes);
None
}
DiagramMessage::SearchBackspace => {
state.search.backspace(&state.nodes);
None
}
DiagramMessage::SearchNext => {
state.search.next_match();
if let Some(idx) = state.search.current_node_index() {
state.selected = Some(idx);
state.selected_node().map(|n| DiagramOutput::SearchMatched {
id: n.id().to_string(),
total_matches: state.search.matches.len(),
})
} else {
None
}
}
DiagramMessage::SearchPrev => {
state.search.prev_match();
if let Some(idx) = state.search.current_node_index() {
state.selected = Some(idx);
state.selected_node().map(|n| DiagramOutput::SearchMatched {
id: n.id().to_string(),
total_matches: state.search.matches.len(),
})
} else {
None
}
}
DiagramMessage::ConfirmSearch => {
if let Some(idx) = state.search.current_node_index() {
state.selected = Some(idx);
let id = state.nodes[idx].id().to_string();
let total = state.search.matches.len();
state.search.cancel();
Some(DiagramOutput::SearchMatched {
id,
total_matches: total,
})
} else {
state.search.cancel();
None
}
}
DiagramMessage::CancelSearch => {
state.search.cancel();
None
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
let mut state_clone = state.clone();
let layout = state_clone.ensure_layout().clone();
render::render_diagram(
state,
&layout,
ctx.frame,
ctx.area,
ctx.theme,
ctx.focused,
ctx.disabled,
);
}
}
#[cfg(test)]
mod snapshot_tests;