Skip to main content

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}