use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use std::time::Duration;
use abstracttui::app::anchored::Tooltip;
use abstracttui::app::{use_theme, Overlays};
use abstracttui::base::{Point, Rect, Rgba};
use abstracttui::layout::{Dimension, Inset, Position, Style as LayoutStyle};
use abstracttui::reactive::{Scope, Signal};
use abstracttui::text::truncate_ellipsis;
use abstracttui::ui::{
dyn_view_scoped, Element, EventCtx, Key, MouseButton, MouseKind, Phase, Role, UiEvent, View,
};
use abstracttui::widgets::Scroll;
use crate::desc::GraphDesc;
use crate::layout::{force, grid, layered, Layout};
#[path = "view_cards.rs"]
mod cards;
#[path = "view_edges.rs"]
mod edges;
#[path = "view_style.rs"]
mod style;
use cards::CardPaint;
pub use style::{GraphAlgo, GraphStyle};
type NodePressFn = Box<dyn FnMut(&str)>;
type BadgeFn = Rc<dyn Fn(&str) -> Option<String>>;
type PressFn = Rc<RefCell<Option<NodePressFn>>>;
pub struct GraphView {
desc: GraphDesc,
algo: GraphAlgo,
layout_override: Option<Layout>,
style: Option<GraphStyle>,
selected: Option<Signal<Option<String>>>,
on_node_press: Option<NodePressFn>,
badges: Option<BadgeFn>,
tooltips: Option<Duration>,
overlays: Option<Overlays>,
offset_x: Option<Signal<i32>>,
offset_y: Option<Signal<i32>>,
layout_style: Option<LayoutStyle>,
}
impl GraphView {
pub fn new(desc: GraphDesc) -> GraphView {
GraphView {
desc,
algo: GraphAlgo::default(),
layout_override: None,
style: None,
selected: None,
on_node_press: None,
badges: None,
tooltips: None,
overlays: None,
offset_x: None,
offset_y: None,
layout_style: None,
}
}
pub fn algo(mut self, algo: GraphAlgo) -> GraphView {
self.algo = algo;
self
}
pub fn with_layout(mut self, layout: Layout) -> GraphView {
self.layout_override = Some(layout);
self
}
pub fn style(mut self, style: GraphStyle) -> GraphView {
self.style = Some(style);
self
}
pub fn selected(mut self, sig: Signal<Option<String>>) -> GraphView {
self.selected = Some(sig);
self
}
pub fn on_node_press(mut self, f: impl FnMut(&str) + 'static) -> GraphView {
self.on_node_press = Some(Box::new(f));
self
}
pub fn badges(mut self, f: impl Fn(&str) -> Option<String> + 'static) -> GraphView {
self.badges = Some(Rc::new(f));
self
}
pub fn tooltips(mut self, delay: Duration) -> GraphView {
self.tooltips = Some(delay);
self
}
pub fn overlays(mut self, overlays: &Overlays) -> GraphView {
self.overlays = Some(overlays.clone());
self
}
pub fn offset_x(mut self, sig: Signal<i32>) -> GraphView {
self.offset_x = Some(sig);
self
}
pub fn offset_y(mut self, sig: Signal<i32>) -> GraphView {
self.offset_y = Some(sig);
self
}
pub fn layout(mut self, layout: LayoutStyle) -> GraphView {
self.layout_style = Some(layout);
self
}
pub fn view(self, cx: Scope) -> View {
let style = Rc::new(match self.style {
Some(s) => s,
None => GraphStyle::from_tokens(&use_theme(cx).get().tokens),
});
let layout = match self.layout_override {
Some(l) => l,
None => match &self.algo {
GraphAlgo::Layered(opts) => layered(&self.desc, opts),
GraphAlgo::Force(opts) => force(&self.desc, opts),
GraphAlgo::Grid => grid(&self.desc),
},
};
let plan = Rc::new(edges::plan_edges(&self.desc, &layout));
let mut meta: HashMap<&str, (&str, Option<&str>)> = HashMap::new();
for n in &self.desc.nodes {
meta.entry(n.id.as_str())
.or_insert((n.label.as_deref().unwrap_or(&n.id), n.kind.as_deref()));
}
let sel: Signal<Option<String>> = self.selected.unwrap_or_else(|| cx.signal(None));
let ox = self.offset_x.unwrap_or_else(|| cx.signal(0i32));
let oy = self.offset_y.unwrap_or_else(|| cx.signal(0i32));
let press: PressFn = Rc::new(RefCell::new(self.on_node_press));
let overlays = self
.overlays
.or_else(|| cx.use_context::<Overlays>())
.filter(|_| self.tooltips.is_some());
let tooltip_delay = self.tooltips.unwrap_or(Duration::ZERO);
let badges = self.badges;
let bounds = layout.bounds;
let (bw, bh) = (bounds.w.max(1), bounds.h.max(1));
let edge_ink = style.edge;
let broken_ink = style.edge_broken;
let label_ink = style.edge_label;
let plan_draw = plan.clone();
let edge_layer = Element::new()
.style(LayoutStyle {
position: Position::Absolute,
inset: Inset {
left: Some(0),
top: Some(0),
right: None,
bottom: None,
},
width: Dimension::Cells(bw),
height: Dimension::Cells(bh),
..LayoutStyle::default()
})
.draw(move |canvas, rect| {
edges::draw_edges(
canvas,
Point::new(rect.x, rect.y),
(bw, bh),
&plan_draw,
edge_ink,
broken_ink,
label_ink,
);
});
let mut content = Element::new()
.style(
LayoutStyle::default()
.width(Dimension::Cells(bw))
.height(Dimension::Cells(bh)),
)
.child(edge_layer.build());
let nav: Rc<Vec<(String, Rect)>> = Rc::new(
layout
.nodes
.iter()
.map(|n| (n.id.clone(), n.rect))
.collect(),
);
for n in &layout.nodes {
let rect = n.rect;
let id: Rc<str> = Rc::from(n.id.as_str());
let (title, kind) = meta
.get(n.id.as_str())
.map(|(t, k)| ((*t).to_string(), k.map(str::to_string)))
.unwrap_or_else(|| (n.id.clone(), None));
let accent = style.accent_of(kind.as_deref());
let tip = tooltip_text(&title, kind.as_deref(), &id);
let abs = LayoutStyle {
position: Position::Absolute,
inset: Inset {
left: Some(rect.x),
top: Some(rect.y),
right: None,
bottom: None,
},
width: Dimension::Cells(rect.w),
height: Dimension::Cells(rect.h),
..LayoutStyle::default()
};
let style = style.clone();
let badges = badges.clone();
let press = press.clone();
let overlays = overlays.clone();
let card = dyn_view_scoped(abs, move |gcx| {
let selected = sel.get().as_deref() == Some(&*id);
let paint = CardPaint {
title: title.clone(),
badge: badges.as_ref().and_then(|f| f(&id)),
accent,
};
let style = style.clone();
let click_id = id.clone();
let click_press = press.clone();
let el = Element::new()
.style(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.role(Role::Button)
.access_label(title.clone())
.on(Phase::Bubble, move |ctx: &mut EventCtx, ev: &UiEvent| {
if let UiEvent::Mouse(m) = ev {
if matches!(m.kind, MouseKind::Up(MouseButton::Left))
&& ctx.current_rect().contains(m.pos)
{
sel.set(Some(click_id.to_string()));
ctx.stop_propagation();
fire_press(&click_press, &click_id);
}
}
})
.draw(move |canvas, rect| {
cards::draw_card(canvas, rect, &style, &paint, selected);
});
let view = el.build();
match &overlays {
Some(ov) => Tooltip::attach(gcx, ov, tip.clone(), tooltip_delay, view),
None => view,
}
});
content = content.child(card);
}
let scroll = Scroll::new(content.build())
.content_size(bw, bh)
.axes(true, true)
.offset_x(ox)
.offset_y(oy)
.scrollbar_auto_hide(true)
.view(cx);
let viewport_probe: Rc<Cell<(i32, i32)>> = Rc::new(Cell::new((0, 0)));
let scroll = {
let probe = viewport_probe.clone();
Element::new()
.style(LayoutStyle::default().grow(1.0).basis(Dimension::Cells(0)))
.draw(move |_canvas, rect| probe.set((rect.w, rect.h)))
.child(scroll)
.build()
};
let notice_rows = i32::from(layout.fallback.is_some());
let notice = layout.fallback.clone().map(|label| {
let ink = style.notice;
let text = format!("⚠ {label}");
Element::new()
.style(
LayoutStyle::default()
.height(Dimension::Cells(1))
.shrink(0.0),
)
.draw(move |canvas, rect| {
if rect.w <= 0 {
return;
}
let t = truncate_ellipsis(&text, rect.w);
canvas.print(rect.origin(), &t, ink, Rgba::TRANSPARENT);
})
.build()
});
let node_count = layout.nodes.len();
let edge_count = layout.edges.len();
let key_handler = {
let nav = nav.clone();
let press = press.clone();
let viewport = viewport_probe.clone();
move |ctx: &mut EventCtx, ev: &UiEvent| {
let UiEvent::Key(k) = ev else { return };
if k.mods != abstracttui::ui::Mods::NONE {
return;
}
match k.key {
Key::Escape => {
if sel.get_untracked().is_some() {
sel.set(None);
ctx.stop_propagation();
}
}
Key::Enter => {
if nav.is_empty() {
return;
}
ctx.stop_propagation();
match sel.get_untracked() {
Some(id) => fire_press(&press, &id),
None => sel.set(Some(nav[0].0.clone())),
}
}
Key::Up | Key::Down | Key::Left | Key::Right => {
let Some(cur) = sel.get_untracked() else {
return; };
ctx.stop_propagation();
let dir = match k.key {
Key::Up => (0, -1),
Key::Down => (0, 1),
Key::Left => (-1, 0),
_ => (1, 0),
};
if let Some(next) = spatial_next(&nav, &cur, dir) {
let rect = nav[next].1;
sel.set(Some(nav[next].0.clone()));
ensure_visible(
viewport.get(),
ctx.current_rect(),
notice_rows,
rect,
ox,
oy,
);
}
}
_ => {}
}
}
};
let mut root = Element::new()
.style(
self.layout_style
.unwrap_or_else(|| LayoutStyle::column().grow(1.0)),
)
.role(Role::Region)
.access_label("graph")
.access_value(move || {
let selected = sel
.get_untracked()
.map(|id| format!(", selected {id}"))
.unwrap_or_default();
format!("{node_count} nodes, {edge_count} edges{selected}")
})
.on(Phase::Capture, key_handler);
if let Some(notice) = notice {
root = root.child(notice);
}
root.child(scroll).build()
}
}
fn fire_press(press: &PressFn, id: &str) {
let press = press.clone(); let taken = press.borrow_mut().take();
if let Some(mut f) = taken {
f(id);
*press.borrow_mut() = Some(f);
}
}
fn tooltip_text(title: &str, kind: Option<&str>, id: &str) -> String {
let mut out = title.to_string();
if let Some(kind) = kind {
out.push_str(&format!(" [{kind}]"));
}
if title != id {
out.push_str(&format!(" ({id})"));
}
out
}
fn spatial_next(nav: &[(String, Rect)], cur_id: &str, dir: (i32, i32)) -> Option<usize> {
let cur = nav.iter().position(|(id, _)| id == cur_id)?;
let c0 = doubled_center(nav[cur].1);
let mut best: Option<(i64, usize)> = None;
for (i, (_, r)) in nav.iter().enumerate() {
if i == cur {
continue;
}
let c = doubled_center(*r);
let (vx, vy) = (i64::from(c.0 - c0.0), i64::from(c.1 - c0.1));
let forward = vx * i64::from(dir.0) + vy * i64::from(dir.1);
if forward <= 0 {
continue;
}
let perp = if dir.0 != 0 { vy.abs() } else { vx.abs() };
let score = forward + 2 * perp;
if best.is_none_or(|(s, _)| score < s) {
best = Some((score, i));
}
}
best.map(|(_, i)| i)
}
fn doubled_center(r: Rect) -> (i32, i32) {
(2 * r.x + r.w, 2 * r.y + r.h)
}
fn ensure_visible(
probed: (i32, i32),
widget: Rect,
notice_rows: i32,
rect: Rect,
ox: Signal<i32>,
oy: Signal<i32>,
) {
let (w, h) = if probed.0 > 0 {
probed
} else {
(widget.w, widget.h - notice_rows)
};
let vw = (w - 1).max(1);
let vh = h.max(1);
let x = ox.get_untracked();
let y = oy.get_untracked();
let nx = clamp_into(x, rect.x, rect.right(), vw);
let ny = clamp_into(y, rect.y, rect.bottom(), vh);
if nx != x {
ox.set(nx);
}
if ny != y {
oy.set(ny);
}
}
fn clamp_into(offset: i32, lo: i32, hi: i32, span: i32) -> i32 {
if lo < offset {
lo
} else if hi > offset + span {
(hi - span).max(0)
} else {
offset
}
}