abstracttui_graph/view.rs
1//! [`GraphView`]: read-only rendering of a [`Layout`] (the view half
2//! of backlog 0440).
3//!
4//! Node CARDS (themed box, title on the border, optional kind-tinted
5//! accent, badge slot), edges as `abstracttui::canvas` strokes
6//! (smoothed beziers through the layout waypoints, arrowheads,
7//! dotted/thick styles, cycle-broken edges visibly distinct), the
8//! layout's `fallback` label as an honest notice line, and pan via
9//! `Scroll` (the layout bounds are the advertised content size).
10//!
11//! ## Interaction vocabulary (one tab stop)
12//!
13//! The view is ONE focus stop (the scroll viewport). While focused:
14//! arrows PAN until a node is selected; **Enter** selects the first
15//! node, then **arrows move the selection spatially** (nearest card
16//! in that direction, deterministic tiebreaks), **Enter presses** the
17//! selected node ([`GraphView::on_node_press`]), **Escape deselects**
18//! (arrows pan again). Clicking a card selects it and presses.
19//! Selection restyles the card border (focus ink + bold title).
20//! Hovering a card shows a passive [`Tooltip`] with the node's
21//! label/kind/id (opt-in, needs an `Overlays` handle — inside an
22//! `App` it resolves from context).
23//!
24//! ## Reactivity + relayout (the honest rule)
25//!
26//! Layout is an ACT at view-build time: `view(cx)` runs the selected
27//! pass once and renders from the cached `Layout`. Data changes
28//! relayout by REBUILDING the view — wrap it in a `dyn_view` over
29//! your data signal, exactly like the chart widgets. A rebuilt force
30//! layout re-runs under its fixed seed (same graph = same picture;
31//! there is no warm-start surface in v1 — cached-position reheat is
32//! the 0430 editor's lane). A parked `GraphView` costs zero idle
33//! (test-pinned).
34//!
35//! Colors are caller-resolved [`GraphStyle`] per the engine's widget
36//! token rule; `view(cx)` derives one from the ACTIVE theme when no
37//! explicit style is given.
38//!
39//! OWNER: CANVAS (view half of 0440; layout half is cycle 1).
40
41use std::cell::{Cell, RefCell};
42use std::collections::HashMap;
43use std::rc::Rc;
44use std::time::Duration;
45
46use abstracttui::app::anchored::Tooltip;
47use abstracttui::app::{use_theme, Overlays};
48use abstracttui::base::{Point, Rect, Rgba};
49use abstracttui::layout::{Dimension, Inset, Position, Style as LayoutStyle};
50use abstracttui::reactive::{Scope, Signal};
51use abstracttui::text::truncate_ellipsis;
52use abstracttui::ui::{
53 dyn_view_scoped, Element, EventCtx, Key, MouseButton, MouseKind, Phase, Role, UiEvent, View,
54};
55use abstracttui::widgets::Scroll;
56
57use crate::desc::GraphDesc;
58use crate::layout::{force, grid, layered, Layout};
59
60#[path = "view_cards.rs"]
61mod cards;
62#[path = "view_edges.rs"]
63mod edges;
64#[path = "view_style.rs"]
65mod style;
66
67use cards::CardPaint;
68pub use style::{GraphAlgo, GraphStyle};
69
70/// Node activation callback (boxed: builder-owned, fired by id).
71type NodePressFn = Box<dyn FnMut(&str)>;
72/// Reactive badge resolver, shared into every card's render scope.
73type BadgeFn = Rc<dyn Fn(&str) -> Option<String>>;
74type PressFn = Rc<RefCell<Option<NodePressFn>>>;
75
76/// Read-only graph widget: computes (or receives) a [`Layout`] and
77/// renders cards + canvas-stroke edges with selection, pan and
78/// tooltips. See the module docs for the interaction vocabulary and
79/// the relayout rule.
80pub struct GraphView {
81 desc: GraphDesc,
82 algo: GraphAlgo,
83 layout_override: Option<Layout>,
84 style: Option<GraphStyle>,
85 selected: Option<Signal<Option<String>>>,
86 on_node_press: Option<NodePressFn>,
87 badges: Option<BadgeFn>,
88 tooltips: Option<Duration>,
89 overlays: Option<Overlays>,
90 offset_x: Option<Signal<i32>>,
91 offset_y: Option<Signal<i32>>,
92 layout_style: Option<LayoutStyle>,
93}
94
95impl GraphView {
96 /// A view over `desc`, laid out by the default layered pass.
97 pub fn new(desc: GraphDesc) -> GraphView {
98 GraphView {
99 desc,
100 algo: GraphAlgo::default(),
101 layout_override: None,
102 style: None,
103 selected: None,
104 on_node_press: None,
105 badges: None,
106 tooltips: None,
107 overlays: None,
108 offset_x: None,
109 offset_y: None,
110 layout_style: None,
111 }
112 }
113
114 /// Select the layout pass (default: layered with default options).
115 pub fn algo(mut self, algo: GraphAlgo) -> GraphView {
116 self.algo = algo;
117 self
118 }
119
120 /// Render a PRECOMPUTED layout instead of running a pass (the
121 /// 0430 hand-positioned seam; `desc` still supplies metadata —
122 /// labels, kinds, edge styles — via `desc_index`/id joins).
123 pub fn with_layout(mut self, layout: Layout) -> GraphView {
124 self.layout_override = Some(layout);
125 self
126 }
127
128 /// Explicit resolved ink set (default: derived from the active
129 /// theme at build).
130 pub fn style(mut self, style: GraphStyle) -> GraphView {
131 self.style = Some(style);
132 self
133 }
134
135 /// Controlled selection: bind the selected node id to an external
136 /// signal (survives rebuilds; an internal signal is used
137 /// otherwise and resets with the view).
138 pub fn selected(mut self, sig: Signal<Option<String>>) -> GraphView {
139 self.selected = Some(sig);
140 self
141 }
142
143 /// Node activation callback: fires on card click and on Enter
144 /// over the selected node. Disposal-safe — the callback may
145 /// dispose the view's scope.
146 pub fn on_node_press(mut self, f: impl FnMut(&str) + 'static) -> GraphView {
147 self.on_node_press = Some(Box::new(f));
148 self
149 }
150
151 /// Reactive badge slot: evaluated per node id inside the card's
152 /// render scope, so signal reads make badges live.
153 pub fn badges(mut self, f: impl Fn(&str) -> Option<String> + 'static) -> GraphView {
154 self.badges = Some(Rc::new(f));
155 self
156 }
157
158 /// Enable hover tooltips (node label/kind/id) with the given
159 /// hover delay. Needs an overlay store: inside an `App` it
160 /// resolves from context, otherwise pass [`GraphView::overlays`];
161 /// without either, tooltips are skipped (documented degradation).
162 pub fn tooltips(mut self, delay: Duration) -> GraphView {
163 self.tooltips = Some(delay);
164 self
165 }
166
167 /// Explicit overlay store for tooltips (tests, bare trees).
168 pub fn overlays(mut self, overlays: &Overlays) -> GraphView {
169 self.overlays = Some(overlays.clone());
170 self
171 }
172
173 /// Bind the horizontal pan offset (overflow-honesty affordances:
174 /// the app can derive "N cells off-screen" from offset + bounds).
175 pub fn offset_x(mut self, sig: Signal<i32>) -> GraphView {
176 self.offset_x = Some(sig);
177 self
178 }
179
180 /// Bind the vertical pan offset.
181 pub fn offset_y(mut self, sig: Signal<i32>) -> GraphView {
182 self.offset_y = Some(sig);
183 self
184 }
185
186 /// Outer layout style (default: a growing column).
187 pub fn layout(mut self, layout: LayoutStyle) -> GraphView {
188 self.layout_style = Some(layout);
189 self
190 }
191
192 /// Build the widget. Layout runs HERE (an act, cached in the
193 /// view); see the module docs for the relayout rule.
194 pub fn view(self, cx: Scope) -> View {
195 let style = Rc::new(match self.style {
196 Some(s) => s,
197 // Tracked theme read: rebuilt-inside-dyn_view callers
198 // retint on theme switch, like core widgets.
199 None => GraphStyle::from_tokens(&use_theme(cx).get().tokens),
200 });
201 let layout = match self.layout_override {
202 Some(l) => l,
203 None => match &self.algo {
204 GraphAlgo::Layered(opts) => layered(&self.desc, opts),
205 GraphAlgo::Force(opts) => force(&self.desc, opts),
206 GraphAlgo::Grid => grid(&self.desc),
207 },
208 };
209 let plan = Rc::new(edges::plan_edges(&self.desc, &layout));
210
211 // Node metadata joins by id (first occurrence wins, matching
212 // the layout's duplicate policy). Lookup-only map.
213 let mut meta: HashMap<&str, (&str, Option<&str>)> = HashMap::new();
214 for n in &self.desc.nodes {
215 meta.entry(n.id.as_str())
216 .or_insert((n.label.as_deref().unwrap_or(&n.id), n.kind.as_deref()));
217 }
218
219 let sel: Signal<Option<String>> = self.selected.unwrap_or_else(|| cx.signal(None));
220 let ox = self.offset_x.unwrap_or_else(|| cx.signal(0i32));
221 let oy = self.offset_y.unwrap_or_else(|| cx.signal(0i32));
222 let press: PressFn = Rc::new(RefCell::new(self.on_node_press));
223 let overlays = self
224 .overlays
225 .or_else(|| cx.use_context::<Overlays>())
226 .filter(|_| self.tooltips.is_some());
227 let tooltip_delay = self.tooltips.unwrap_or(Duration::ZERO);
228 let badges = self.badges;
229
230 let bounds = layout.bounds;
231 let (bw, bh) = (bounds.w.max(1), bounds.h.max(1));
232
233 // ---- edge layer (under the cards) --------------------------
234 let edge_ink = style.edge;
235 let broken_ink = style.edge_broken;
236 let label_ink = style.edge_label;
237 let plan_draw = plan.clone();
238 let edge_layer = Element::new()
239 .style(LayoutStyle {
240 position: Position::Absolute,
241 inset: Inset {
242 left: Some(0),
243 top: Some(0),
244 right: None,
245 bottom: None,
246 },
247 width: Dimension::Cells(bw),
248 height: Dimension::Cells(bh),
249 ..LayoutStyle::default()
250 })
251 .draw(move |canvas, rect| {
252 edges::draw_edges(
253 canvas,
254 Point::new(rect.x, rect.y),
255 (bw, bh),
256 &plan_draw,
257 edge_ink,
258 broken_ink,
259 label_ink,
260 );
261 });
262
263 // ---- node cards (dyn per card: a selection change damages
264 // exactly the two affected card regions) ----------------
265 let mut content = Element::new()
266 .style(
267 LayoutStyle::default()
268 .width(Dimension::Cells(bw))
269 .height(Dimension::Cells(bh)),
270 )
271 .child(edge_layer.build());
272 // Navigation facts for the key handler: (id, rect) per node.
273 let nav: Rc<Vec<(String, Rect)>> = Rc::new(
274 layout
275 .nodes
276 .iter()
277 .map(|n| (n.id.clone(), n.rect))
278 .collect(),
279 );
280 for n in &layout.nodes {
281 let rect = n.rect;
282 let id: Rc<str> = Rc::from(n.id.as_str());
283 let (title, kind) = meta
284 .get(n.id.as_str())
285 .map(|(t, k)| ((*t).to_string(), k.map(str::to_string)))
286 .unwrap_or_else(|| (n.id.clone(), None));
287 let accent = style.accent_of(kind.as_deref());
288 let tip = tooltip_text(&title, kind.as_deref(), &id);
289 let abs = LayoutStyle {
290 position: Position::Absolute,
291 inset: Inset {
292 left: Some(rect.x),
293 top: Some(rect.y),
294 right: None,
295 bottom: None,
296 },
297 width: Dimension::Cells(rect.w),
298 height: Dimension::Cells(rect.h),
299 ..LayoutStyle::default()
300 };
301 let style = style.clone();
302 let badges = badges.clone();
303 let press = press.clone();
304 let overlays = overlays.clone();
305 let card = dyn_view_scoped(abs, move |gcx| {
306 let selected = sel.get().as_deref() == Some(&*id);
307 let paint = CardPaint {
308 title: title.clone(),
309 badge: badges.as_ref().and_then(|f| f(&id)),
310 accent,
311 };
312 let style = style.clone();
313 let click_id = id.clone();
314 let click_press = press.clone();
315 let el = Element::new()
316 .style(
317 LayoutStyle::default()
318 .width(Dimension::Percent(1.0))
319 .height(Dimension::Percent(1.0)),
320 )
321 .role(Role::Button)
322 .access_label(title.clone())
323 .on(Phase::Bubble, move |ctx: &mut EventCtx, ev: &UiEvent| {
324 if let UiEvent::Mouse(m) = ev {
325 // RELEASE-INSIDE fires — the engine's
326 // Button convention. Firing on DOWN left
327 // the tree's pointer capture STUCK when
328 // `on_node_press` opened a MODAL (drawer,
329 // dialog): the release routed to the
330 // overlay, the capture never dropped, and
331 // every later click anywhere pressed this
332 // card again (found by the wave-9
333 // acceptance battery's tab click).
334 if matches!(m.kind, MouseKind::Up(MouseButton::Left))
335 && ctx.current_rect().contains(m.pos)
336 {
337 sel.set(Some(click_id.to_string()));
338 ctx.stop_propagation();
339 fire_press(&click_press, &click_id);
340 }
341 }
342 })
343 .draw(move |canvas, rect| {
344 cards::draw_card(canvas, rect, &style, &paint, selected);
345 });
346 let view = el.build();
347 match &overlays {
348 Some(ov) => Tooltip::attach(gcx, ov, tip.clone(), tooltip_delay, view),
349 None => view,
350 }
351 });
352 content = content.child(card);
353 }
354
355 let scroll = Scroll::new(content.build())
356 .content_size(bw, bh)
357 .axes(true, true)
358 .offset_x(ox)
359 .offset_y(oy)
360 // A fitting graph shows no bar (the column is still
361 // reserved, painted as ground — engine contract).
362 .scrollbar_auto_hide(true)
363 .view(cx);
364 // Viewport probe (cycle-3 ensure_visible fix): record the
365 // scroll host's SOLVED rect at paint time into a plain cell
366 // (no signal writes in draw — the RT1-2 law; key handlers
367 // read the last-painted value). This excludes root padding
368 // and the notice row by construction, where the old
369 // widget-rect approximation drifted under padded layouts.
370 let viewport_probe: Rc<Cell<(i32, i32)>> = Rc::new(Cell::new((0, 0)));
371 let scroll = {
372 let probe = viewport_probe.clone();
373 Element::new()
374 .style(LayoutStyle::default().grow(1.0).basis(Dimension::Cells(0)))
375 .draw(move |_canvas, rect| probe.set((rect.w, rect.h)))
376 .child(scroll)
377 .build()
378 };
379
380 // ---- notice line (honesty: never scrolls away) -------------
381 let notice_rows = i32::from(layout.fallback.is_some());
382 let notice = layout.fallback.clone().map(|label| {
383 let ink = style.notice;
384 let text = format!("⚠ {label}");
385 Element::new()
386 .style(
387 LayoutStyle::default()
388 .height(Dimension::Cells(1))
389 .shrink(0.0),
390 )
391 .draw(move |canvas, rect| {
392 if rect.w <= 0 {
393 return;
394 }
395 let t = truncate_ellipsis(&text, rect.w);
396 canvas.print(rect.origin(), &t, ink, Rgba::TRANSPARENT);
397 })
398 .build()
399 });
400
401 // ---- root: one capture-phase key vocabulary ----------------
402 let node_count = layout.nodes.len();
403 let edge_count = layout.edges.len();
404 let key_handler = {
405 let nav = nav.clone();
406 let press = press.clone();
407 let viewport = viewport_probe.clone();
408 move |ctx: &mut EventCtx, ev: &UiEvent| {
409 let UiEvent::Key(k) = ev else { return };
410 // Plain keys only: modified arrows/Enter stay available
411 // to container chords above (the engine's PageHost
412 // lesson — never consume modifier combinations you do
413 // not implement).
414 if k.mods != abstracttui::ui::Mods::NONE {
415 return;
416 }
417 match k.key {
418 Key::Escape => {
419 if sel.get_untracked().is_some() {
420 sel.set(None);
421 ctx.stop_propagation();
422 }
423 }
424 Key::Enter => {
425 if nav.is_empty() {
426 return;
427 }
428 ctx.stop_propagation();
429 match sel.get_untracked() {
430 Some(id) => fire_press(&press, &id),
431 None => sel.set(Some(nav[0].0.clone())),
432 }
433 }
434 Key::Up | Key::Down | Key::Left | Key::Right => {
435 let Some(cur) = sel.get_untracked() else {
436 return; // no selection: arrows PAN (Scroll)
437 };
438 // Consume even at a boundary — mid-navigation
439 // arrows must never surprise-pan.
440 ctx.stop_propagation();
441 let dir = match k.key {
442 Key::Up => (0, -1),
443 Key::Down => (0, 1),
444 Key::Left => (-1, 0),
445 _ => (1, 0),
446 };
447 if let Some(next) = spatial_next(&nav, &cur, dir) {
448 let rect = nav[next].1;
449 sel.set(Some(nav[next].0.clone()));
450 ensure_visible(
451 viewport.get(),
452 ctx.current_rect(),
453 notice_rows,
454 rect,
455 ox,
456 oy,
457 );
458 }
459 }
460 _ => {}
461 }
462 }
463 };
464
465 let mut root = Element::new()
466 .style(
467 self.layout_style
468 .unwrap_or_else(|| LayoutStyle::column().grow(1.0)),
469 )
470 .role(Role::Region)
471 .access_label("graph")
472 .access_value(move || {
473 let selected = sel
474 .get_untracked()
475 .map(|id| format!(", selected {id}"))
476 .unwrap_or_default();
477 format!("{node_count} nodes, {edge_count} edges{selected}")
478 })
479 .on(Phase::Capture, key_handler);
480 if let Some(notice) = notice {
481 root = root.child(notice);
482 }
483 root.child(scroll).build()
484 }
485}
486
487/// Fire the press callback last, holding NO borrow across the call
488/// (take-call-restore): the callback may dispose the view's scope
489/// (the engine's 0297 disposal-safety law) — our `Rc` clone keeps the
490/// slot alive — and a reentrant press during the callback sees an
491/// empty slot instead of a RefCell panic.
492fn fire_press(press: &PressFn, id: &str) {
493 let press = press.clone(); // keep the slot alive through disposal
494 let taken = press.borrow_mut().take();
495 if let Some(mut f) = taken {
496 f(id);
497 *press.borrow_mut() = Some(f);
498 }
499}
500
501fn tooltip_text(title: &str, kind: Option<&str>, id: &str) -> String {
502 let mut out = title.to_string();
503 if let Some(kind) = kind {
504 out.push_str(&format!(" [{kind}]"));
505 }
506 if title != id {
507 out.push_str(&format!(" ({id})"));
508 }
509 out
510}
511
512/// Nearest node strictly in direction `dir` from the selected one:
513/// candidates must lie forward along the axis (doubled-center integer
514/// math, no floats); score = forward distance + 2x perpendicular
515/// offset, ties to the earliest node (input order) — deterministic.
516///
517/// The vocabulary is ALIGNED-FIRST: doubling the perpendicular cost
518/// means Down from a diamond's apex lands on the aligned sink below,
519/// not a diagonal flank — flanks are one more arrow away (test-pinned
520/// in view_interact.rs). Predictable beats rank-stepping here: force
521/// layouts have no ranks, and one rule must serve every pass.
522fn spatial_next(nav: &[(String, Rect)], cur_id: &str, dir: (i32, i32)) -> Option<usize> {
523 let cur = nav.iter().position(|(id, _)| id == cur_id)?;
524 let c0 = doubled_center(nav[cur].1);
525 let mut best: Option<(i64, usize)> = None;
526 for (i, (_, r)) in nav.iter().enumerate() {
527 if i == cur {
528 continue;
529 }
530 let c = doubled_center(*r);
531 let (vx, vy) = (i64::from(c.0 - c0.0), i64::from(c.1 - c0.1));
532 let forward = vx * i64::from(dir.0) + vy * i64::from(dir.1);
533 if forward <= 0 {
534 continue;
535 }
536 let perp = if dir.0 != 0 { vy.abs() } else { vx.abs() };
537 let score = forward + 2 * perp;
538 if best.is_none_or(|(s, _)| score < s) {
539 best = Some((score, i));
540 }
541 }
542 best.map(|(_, i)| i)
543}
544
545fn doubled_center(r: Rect) -> (i32, i32) {
546 (2 * r.x + r.w, 2 * r.y + r.h)
547}
548
549/// Clamp the pan offsets so `rect` (content cells) is visible in the
550/// viewport. The primary source is the paint-time PROBE of the scroll
551/// host's solved rect (padding and the notice row excluded by
552/// construction — the cycle-3 padded-root fix); the widget-rect
553/// approximation remains the pre-first-paint fallback. Minus one
554/// column either way: the scrollbar strip is always reserved.
555/// Scroll's own repair effect re-clamps against the true max, so an
556/// over-ask is safe.
557fn ensure_visible(
558 probed: (i32, i32),
559 widget: Rect,
560 notice_rows: i32,
561 rect: Rect,
562 ox: Signal<i32>,
563 oy: Signal<i32>,
564) {
565 let (w, h) = if probed.0 > 0 {
566 probed
567 } else {
568 (widget.w, widget.h - notice_rows)
569 };
570 let vw = (w - 1).max(1);
571 let vh = h.max(1);
572 let x = ox.get_untracked();
573 let y = oy.get_untracked();
574 let nx = clamp_into(x, rect.x, rect.right(), vw);
575 let ny = clamp_into(y, rect.y, rect.bottom(), vh);
576 if nx != x {
577 ox.set(nx);
578 }
579 if ny != y {
580 oy.set(ny);
581 }
582}
583
584/// Smallest offset change bringing [lo, hi) into a window of `span`.
585fn clamp_into(offset: i32, lo: i32, hi: i32, span: i32) -> i32 {
586 if lo < offset {
587 lo
588 } else if hi > offset + span {
589 (hi - span).max(0)
590 } else {
591 offset
592 }
593}