Skip to main content

kyu_visualizer/
app.rs

1//! Top-level app view: creates state signals, wires deps, composes UI.
2
3use std::sync::Arc;
4
5use blinc_app::prelude::*;
6use blinc_app::windowed::WindowedContext;
7use blinc_core::State;
8use kyu_api::Database;
9
10use crate::canvas::{interaction, renderer};
11use crate::graph::{layout, loader};
12use crate::state::{CameraState, GraphData, SchemaData, Selection, Vec2};
13use crate::theme;
14use crate::transitions::CanvasMode;
15use crate::ui::{inspector, query_bar, sidebar};
16
17/// Data loaded once before the render loop starts.
18pub struct InitialData {
19    pub schema: SchemaData,
20    pub graph: GraphData,
21    pub layout_temp: f32,
22}
23
24/// Load schema + initial graph synchronously before entering the render loop.
25pub fn load_initial_data(db: &Database) -> InitialData {
26    let schema = loader::load_schema(db);
27
28    let conn = db.connect();
29    let mut graph = match loader::load_full_graph(&conn, db) {
30        Ok(g) => g,
31        Err(_) => GraphData::new(),
32    };
33
34    // Run layout to completion synchronously before the render loop.
35    if !graph.nodes.is_empty() {
36        let mut temp = layout::reset_temperature(graph.nodes.len());
37        while layout::layout_batch(&mut graph, &mut temp, 10) {}
38        layout::center_graph(&mut graph);
39    }
40
41    InitialData {
42        schema,
43        graph,
44        layout_temp: 0.0,
45    }
46}
47
48/// Build the entire UI. Called from `WindowedApp::run`.
49///
50/// All state is initialized from `initial` (loaded once at startup).
51/// `build_ui` is purely declarative — it wires signals to UI elements.
52pub fn build_ui(
53    ctx: &WindowedContext,
54    db: Arc<Database>,
55    initial: &InitialData,
56) -> impl ElementBuilder + use<> {
57    // ── Create state signals (seeded from pre-loaded data) ──
58    let graph_data = ctx.use_state_keyed("graph_data", || initial.graph.clone());
59    let camera = ctx.use_state_keyed("camera", CameraState::default);
60    let selection = ctx.use_state_keyed("selection", || Selection::None);
61    let query_input = text_input_state_with_placeholder("MATCH (n) RETURN n LIMIT 100");
62    let query_error = ctx.use_state_keyed("query_error", || Option::<String>::None);
63    let schema_data = ctx.use_state_keyed("schema_data", || initial.schema.clone());
64    let layout_temp = ctx.use_state_keyed("layout_temp", || initial.layout_temp);
65    let dragged_node = ctx.use_state_keyed("dragged_node", || Option::<usize>::None);
66    let prev_drag = ctx.use_state_keyed("prev_drag", || (0.0_f32, 0.0_f32));
67
68    // ── Sidebar: on table click → center camera on that table's nodes ──
69    let sidebar_on_click = {
70        let gd = graph_data.clone();
71        let cam = camera.clone();
72        let sel = selection.clone();
73        move |table_name: &str| {
74            let graph = gd.get();
75            // Collect positions of nodes matching this label (node table)
76            // or connected by this rel type (rel table).
77            let positions: Vec<Vec2> = {
78                let node_matches: Vec<Vec2> = graph
79                    .nodes
80                    .iter()
81                    .filter(|n| n.label == table_name)
82                    .map(|n| n.pos)
83                    .collect();
84                if !node_matches.is_empty() {
85                    node_matches
86                } else {
87                    // Rel table: collect src + dst nodes of matching edges.
88                    graph
89                        .edges
90                        .iter()
91                        .filter(|e| e.rel_type == table_name)
92                        .flat_map(|e| [graph.nodes[e.src].pos, graph.nodes[e.dst].pos])
93                        .collect()
94                }
95            };
96            if positions.is_empty() {
97                return;
98            }
99            let n = positions.len() as f32;
100            let cx = positions.iter().map(|p| p.x).sum::<f32>() / n;
101            let cy = positions.iter().map(|p| p.y).sum::<f32>() / n;
102            // Offset is screen-space, applied before zoom.
103            // To center world point (cx, cy): offset = -cx * zoom, -cy * zoom.
104            let mut camera = cam.get();
105            camera.offset_x = -cx * camera.zoom;
106            camera.offset_y = -cy * camera.zoom;
107            cam.set(camera);
108            sel.set(Selection::None);
109        }
110    };
111
112    // ── Query bar: on execute → run query ──
113    let conn_query = Arc::new(db.connect());
114    let on_execute = {
115        let conn = conn_query.clone();
116        let qe = query_error.clone();
117        let qi = query_input.clone();
118        move |_: ()| {
119            let cypher = qi.lock().unwrap().value.clone();
120            if cypher.trim().is_empty() {
121                return;
122            }
123            match conn.query(&cypher) {
124                Ok(_result) => {
125                    qe.set(None);
126                    // TODO: parse query result into graph nodes/edges
127                }
128                Err(e) => {
129                    qe.set(Some(format!("{e}")));
130                }
131            }
132        }
133    };
134
135    // ── Canvas area with CanvasMode state chart ──
136    let canvas_view = build_canvas_view(
137        graph_data.clone(),
138        camera.clone(),
139        selection.clone(),
140        layout_temp.clone(),
141        dragged_node.clone(),
142        prev_drag.clone(),
143        ctx.width,
144        ctx.height,
145    );
146
147    // ── Compose layout ──
148    //
149    // #app (column)
150    //   #header (40px)
151    //   #main (row, flex:1)
152    //     #sidebar (280px, column)
153    //       #schema-browser
154    //       #inspector
155    //     #canvas-area (flex:1)
156    //   #query-bar (50px)
157    //
158    div()
159        .id("app")
160        .flex_col()
161        .w(ctx.width)
162        .h(ctx.height)
163        .child(
164            div()
165                .id("header")
166                .h(40.0)
167                .flex_row()
168                .items_center()
169                .p(4.0)
170                .bg(theme::BG)
171                .border_bottom(1.0, theme::BORDER)
172                .child(h2("KyuGraph Visualizer").color(theme::ACCENT)),
173        )
174        .child(
175            div()
176                .id("main")
177                .flex_grow()
178                .child(
179                    div()
180                        .id("sidebar")
181                        .flex_col()
182                        .w(280.0)
183                        .flex_shrink()
184                        .border_right(1.0, theme::BORDER)
185                        .child(sidebar::sidebar_view(schema_data.clone(), sidebar_on_click))
186                        .child(inspector::inspector_view(
187                            selection.clone(),
188                            graph_data.clone(),
189                        )),
190                )
191                .child(div().id("canvas-wrap").flex_grow().child(canvas_view)),
192        )
193        .child(query_bar::query_bar_view(
194            query_error,
195            query_input.clone(),
196            on_execute,
197        ))
198}
199
200/// Build the canvas area with CanvasMode state chart for interaction.
201#[allow(clippy::too_many_arguments)]
202fn build_canvas_view(
203    graph_data: State<GraphData>,
204    camera: State<CameraState>,
205    selection: State<Selection>,
206    _layout_temp: State<f32>,
207    dragged_node: State<Option<usize>>,
208    prev_drag: State<(f32, f32)>,
209    window_w: f32,
210    window_h: f32,
211) -> impl ElementBuilder {
212    let gd = graph_data.clone();
213    let cam = camera.clone();
214    let sel = selection.clone();
215
216    stateful::<CanvasMode>()
217        .deps([
218            graph_data.signal_id(),
219            camera.signal_id(),
220            selection.signal_id(),
221        ])
222        .on_state(move |_ctx| {
223            let gd = gd.clone();
224            let cam = cam.clone();
225            let sel = sel.clone();
226
227            div().id("canvas-area").w_full().h_full().child(
228                canvas(move |draw_ctx, bounds| {
229                    let graph = gd.get();
230                    let camera = cam.get();
231                    let sel = sel.get();
232                    renderer::render_graph(
233                        draw_ctx,
234                        bounds.width,
235                        bounds.height,
236                        &graph,
237                        &camera,
238                        &sel,
239                    );
240                })
241                .w_full()
242                .h_full(),
243            )
244        })
245        .on_click({
246            let gd = graph_data.clone();
247            let cam = camera.clone();
248            let sel = selection.clone();
249            move |evt| {
250                let graph = gd.get();
251                let camera = cam.get();
252                let world = interaction::screen_to_world(
253                    Vec2::new(evt.local_x, evt.local_y),
254                    &camera,
255                    window_w - 280.0,
256                    window_h - 50.0,
257                );
258                let hit = interaction::hit_test(world, &graph);
259                sel.set(hit);
260            }
261        })
262        .on_mouse_down({
263            let gd = graph_data.clone();
264            let cam = camera.clone();
265            let dn = dragged_node.clone();
266            let pd = prev_drag.clone();
267            move |evt| {
268                pd.set((0.0, 0.0));
269                let graph = gd.get();
270                let camera = cam.get();
271                let world = interaction::screen_to_world(
272                    Vec2::new(evt.local_x, evt.local_y),
273                    &camera,
274                    window_w - 280.0,
275                    window_h - 50.0,
276                );
277                if let Some(idx) = interaction::hit_test_node(world, &graph) {
278                    dn.set(Some(idx));
279                } else {
280                    dn.set(None);
281                }
282            }
283        })
284        .on_drag({
285            let gd = graph_data.clone();
286            let cam = camera.clone();
287            let dn = dragged_node.clone();
288            let pd = prev_drag.clone();
289            move |evt| {
290                // drag_delta is cumulative from drag start; compute per-frame delta.
291                let (prev_x, prev_y) = pd.get();
292                let dx = evt.drag_delta_x - prev_x;
293                let dy = evt.drag_delta_y - prev_y;
294                pd.set((evt.drag_delta_x, evt.drag_delta_y));
295
296                if let Some(idx) = dn.get() {
297                    let mut graph = gd.get();
298                    let camera = cam.get();
299                    if let Some(node) = graph.nodes.get_mut(idx) {
300                        node.pos.x += dx / camera.zoom;
301                        node.pos.y += dy / camera.zoom;
302                        node.pinned = true;
303                    }
304                    gd.set(graph);
305                } else {
306                    let mut camera = cam.get();
307                    camera.offset_x += dx;
308                    camera.offset_y += dy;
309                    cam.set(camera);
310                }
311            }
312        })
313        .on_drag_end({
314            let dn = dragged_node.clone();
315            move |_| {
316                dn.set(None);
317            }
318        })
319        .on_scroll({
320            let cam = camera.clone();
321            move |evt| {
322                let mut camera = cam.get();
323                let zoom_delta = evt.scroll_delta_y * 0.001;
324                camera.zoom *= 1.0 + zoom_delta;
325                camera.clamp_zoom();
326                cam.set(camera);
327            }
328        })
329        .w_full()
330        .h_full()
331}