clin-rs 0.5.2

Encrypted terminal note-taking app
use std::sync::{Arc, RwLock};
use std::time::Instant;

use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;

use super::viewport::CELL_ASPECT;
use super::GraphState;
use crate::graf::config::GrafConfig;

#[derive(Debug)]
pub enum GraphAction {
    Quit,
    OpenFile(String),
    ToggleHelp,
    ToggleSearch,
    ToggleMinimap,
    ToggleLegend,
    ToggleGrid,
    ToggleStatus,
    ReloadConfig,
    Refresh,
}

pub fn handle_graph_keys(
    state: &Arc<RwLock<GraphState>>,
    key: KeyEvent,
    config: &GrafConfig,
) -> Option<GraphAction> {
    let mut guard = state.write().unwrap_or_else(|e| e.into_inner());

    let ctrl = key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL);

    match key.code {
        crossterm::event::KeyCode::Esc | crossterm::event::KeyCode::Char('q') => {
            return Some(GraphAction::Quit);
        }
        crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') if !ctrl => {
            select_in_direction(&mut guard, 0.0, 1.0);
        }
        crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') if !ctrl => {
            select_in_direction(&mut guard, 0.0, -1.0);
        }
        crossterm::event::KeyCode::Left | crossterm::event::KeyCode::Char('h') if !ctrl => {
            select_in_direction(&mut guard, -1.0, 0.0);
        }
        crossterm::event::KeyCode::Right | crossterm::event::KeyCode::Char('l') if !ctrl => {
            select_in_direction(&mut guard, 1.0, 0.0);
        }
        crossterm::event::KeyCode::Char('+') | crossterm::event::KeyCode::Char('=') => {
            guard.viewport.zoom_in(config.interaction.zoom_factor);
        }
        crossterm::event::KeyCode::Char('j') if ctrl => {
            guard.viewport.zoom_in(config.interaction.zoom_factor);
        }
        crossterm::event::KeyCode::Char('-') => {
            guard.viewport.zoom_out(config.interaction.zoom_factor);
        }
        crossterm::event::KeyCode::Char('k') if ctrl => {
            guard.viewport.zoom_out(config.interaction.zoom_factor);
        }
        crossterm::event::KeyCode::Enter => {
            if let Some(idx) = guard.selected_node
                && let Some(node) = guard.simulation.get_graph().node_weight(idx) {
                    return Some(GraphAction::OpenFile(node.data.note_id.clone()));
                }
        }
        crossterm::event::KeyCode::Char('a') => {
            let vp = guard.viewport.clone().auto_fit_from_graph(
                guard.simulation.get_graph(),
                config.interaction.auto_fit_padding,
            );
            guard.viewport = vp;
        }
        crossterm::event::KeyCode::Char('r') if ctrl => {
            return Some(GraphAction::ReloadConfig);
        }
        crossterm::event::KeyCode::Char('r') => {
            return Some(GraphAction::Refresh);
        }
        crossterm::event::KeyCode::Char('f') => {
            return Some(GraphAction::ToggleSearch);
        }
        crossterm::event::KeyCode::Char('?') => {
            return Some(GraphAction::ToggleHelp);
        }
        crossterm::event::KeyCode::Char('M') => {
            return Some(GraphAction::ToggleMinimap);
        }
        crossterm::event::KeyCode::Char('L') => {
            return Some(GraphAction::ToggleLegend);
        }
        crossterm::event::KeyCode::Char('G') => {
            return Some(GraphAction::ToggleGrid);
        }
        crossterm::event::KeyCode::Char('S') => {
            return Some(GraphAction::ToggleStatus);
        }
        _ => {}
    }

    None
}

#[derive(Default)]
pub struct GraphMouseState {
    pub drag_origin: Option<(u16, u16)>,
    pub is_panning: bool,
    pub last_click_time: Option<Instant>,
    pub last_clicked_node: Option<fdg_sim::petgraph::graph::NodeIndex>,
    pub is_minimap_dragging: bool,
}

pub fn handle_graph_mouse(
    state: &Arc<RwLock<GraphState>>,
    mouse_event: MouseEvent,
    area: Rect,
    mouse_state: &mut GraphMouseState,
    config: &GrafConfig,
) -> Option<GraphAction> {
    let minimap_area = if config.visual.show_minimap {
        Some(super::render::compute_minimap_area(area, config))
    } else {
        None
    };

    let in_minimap = minimap_area.is_some_and(|ma| {
        mouse_event.column >= ma.x
            && mouse_event.column < ma.x + ma.width
            && mouse_event.row >= ma.y
            && mouse_event.row < ma.y + ma.height
    });

    match mouse_event.kind {
        MouseEventKind::ScrollUp => {
            let mut guard = state.write().unwrap_or_else(|e| e.into_inner());
            guard.viewport.zoom_in(config.interaction.zoom_factor);
        }
        MouseEventKind::ScrollDown => {
            let mut guard = state.write().unwrap_or_else(|e| e.into_inner());
            guard.viewport.zoom_out(config.interaction.zoom_factor);
        }
        MouseEventKind::Down(MouseButton::Left) => {
            if in_minimap {
                if let Some(ma) = minimap_area {
                    let world = minimap_screen_to_world(
                        mouse_event.column,
                        mouse_event.row,
                        ma,
                        &state.read().unwrap_or_else(|e| e.into_inner()),
                    );
                    let mut guard = state.write().unwrap_or_else(|e| e.into_inner());
                    guard.viewport.center_x = world.0;
                    guard.viewport.center_y = world.1;
                    mouse_state.is_minimap_dragging = true;
                    mouse_state.drag_origin = Some((mouse_event.column, mouse_event.row));
                }
            } else {
                let (wx, wy) = {
                    let guard = state.read().unwrap_or_else(|e| e.into_inner());
                    guard
                        .viewport
                        .screen_to_world(mouse_event.column, mouse_event.row, area)
                };

                let hit = {
                    let guard = state.read().unwrap_or_else(|e| e.into_inner());
                    guard.viewport.hit_test(wx, wy, &guard)
                };

                let is_double_click = mouse_state.last_click_time.is_some_and(|t| {
                    t.elapsed().as_millis() < config.interaction.double_click_ms as u128
                });

                if let Some(node_idx) = hit {
                    let mut guard = state.write().unwrap_or_else(|e| e.into_inner());
                    guard.selected_node = Some(node_idx);
                    guard.dragging_node = Some(node_idx);
                    mouse_state.drag_origin = Some((mouse_event.column, mouse_event.row));
                    mouse_state.is_panning = false;
                    mouse_state.last_clicked_node = Some(node_idx);

                    if is_double_click
                        && let Some(node) = guard.simulation.get_graph().node_weight(node_idx) {
                            mouse_state.last_click_time = Some(Instant::now());
                            return Some(GraphAction::OpenFile(node.data.note_id.clone()));
                        }
                } else {
                    let mut guard = state.write().unwrap_or_else(|e| e.into_inner());
                    if is_double_click {
                        guard.selected_node = None;
                    }
                    guard.dragging_node = None;
                    mouse_state.drag_origin = Some((mouse_event.column, mouse_event.row));
                    mouse_state.is_panning = true;
                    mouse_state.last_clicked_node = None;
                }
            }
        }
        MouseEventKind::Drag(MouseButton::Left) => {
            let (orig_col, orig_row) = mouse_state.drag_origin?;

            if mouse_state.is_minimap_dragging {
                if let Some(ma) = minimap_area {
                    let world = minimap_screen_to_world(
                        mouse_event.column,
                        mouse_event.row,
                        ma,
                        &state.read().unwrap_or_else(|e| e.into_inner()),
                    );
                    let mut guard = state.write().unwrap_or_else(|e| e.into_inner());
                    guard.viewport.center_x = world.0;
                    guard.viewport.center_y = world.1;
                    mouse_state.drag_origin = Some((mouse_event.column, mouse_event.row));
                }
            } else if mouse_state.is_panning {
                let mut guard = state.write().unwrap_or_else(|e| e.into_inner());
                let dx_col = -(mouse_event.column as f64 - orig_col as f64);
                let dy_row = mouse_event.row as f64 - orig_row as f64;
                let vp = &guard.viewport;
                let world_dx = dx_col * config.interaction.drag_scale
                    / (vp.zoom * area.width as f64)
                    * config.interaction.drag_sensitivity;
                let world_dy = dy_row * config.interaction.drag_scale * CELL_ASPECT
                    / (vp.zoom * area.height as f64)
                    * config.interaction.drag_sensitivity;
                guard.viewport.center_x += world_dx;
                guard.viewport.center_y += world_dy;
                mouse_state.drag_origin = Some((mouse_event.column, mouse_event.row));
            } else {
                let (wx, wy) = {
                    let guard = state.read().unwrap_or_else(|e| e.into_inner());
                    guard
                        .viewport
                        .screen_to_world(mouse_event.column, mouse_event.row, area)
                };

                let mut guard = state.write().unwrap_or_else(|e| e.into_inner());
                if let Some(node_idx) = guard.dragging_node {
                    let graph = guard.simulation.get_graph_mut();
                    if let Some(node) = graph.node_weight_mut(node_idx) {
                        node.location.x = wx as f32;
                        node.location.y = wy as f32;
                        node.velocity = fdg_sim::glam::Vec3::ZERO;
                    }
                    guard.drag_target = Some((wx as f32, wy as f32));
                    guard.is_settled = false;
                }
                mouse_state.drag_origin = Some((mouse_event.column, mouse_event.row));
            }
        }
        MouseEventKind::Up(MouseButton::Left) => {
            {
                let mut guard = state.write().unwrap_or_else(|e| e.into_inner());
                guard.dragging_node = None;
                guard.drag_target = None;
            }
            mouse_state.drag_origin = None;
            mouse_state.is_panning = false;
            mouse_state.is_minimap_dragging = false;
            mouse_state.last_click_time = Some(Instant::now());
        }
        _ => {}
    }

    None
}

fn select_in_direction(guard: &mut GraphState, dx: f64, dy: f64) {
    if guard.selected_node.is_none() {
        guard.selected_node = guard.viewport.nearest_to_center(guard);
        if let Some(idx) = guard.selected_node {
            let graph = guard.simulation.get_graph();
            let node = &graph[idx];
            guard
                .viewport
                .center_on_node(node.location.x, node.location.y);
        }
        return;
    }

    let (ox, oy) = {
        let graph = guard.simulation.get_graph();
        let idx = guard.selected_node.unwrap();
        let node = &graph[idx];
        (node.location.x as f64, node.location.y as f64)
    };

    if let Some(next) =
        guard
            .viewport
            .nearest_in_direction(guard, ox, oy, dx, dy, guard.selected_node)
    {
        guard.selected_node = Some(next);
        let graph = guard.simulation.get_graph();
        let node = &graph[next];
        guard
            .viewport
            .center_on_node(node.location.x, node.location.y);
    }
}

fn minimap_screen_to_world(
    col: u16,
    row: u16,
    minimap_area: Rect,
    state: &GraphState,
) -> (f64, f64) {
    let (wx_min, wx_max, wy_min, wy_max) = state.graph_bounds;
    let inner_x = minimap_area.x + 1;
    let inner_y = minimap_area.y + 1;
    let inner_w = minimap_area.width.saturating_sub(2);
    let inner_h = minimap_area.height.saturating_sub(2);

    if inner_w == 0 || inner_h == 0 {
        return (0.0, 0.0);
    }

    let rel_x = (col as f64 - inner_x as f64) / inner_w as f64;
    let rel_y = 1.0 - (row as f64 - inner_y as f64) / inner_h as f64;

    let wx = wx_min + rel_x * (wx_max - wx_min);
    let wy = wy_min + rel_y * (wy_max - wy_min);
    (wx, wy)
}