clin-rs 0.8.23

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

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

use super::graph::GraphState;
use super::viewport::CELL_ASPECT;
use crate::config::ClinConfig;
use crate::keybinds::{GraphAction, Keybinds};

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

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

    if keybinds.matches_graph(GraphAction::Quit, &key) {
        return Some(GraphInputAction::Quit);
    }

    if keybinds.matches_graph(GraphAction::PanUp, &key) {
        select_in_direction(&mut guard, 0.0, 1.0);
    } else if keybinds.matches_graph(GraphAction::PanDown, &key) {
        select_in_direction(&mut guard, 0.0, -1.0);
    } else if keybinds.matches_graph(GraphAction::PanLeft, &key) {
        select_in_direction(&mut guard, -1.0, 0.0);
    } else if keybinds.matches_graph(GraphAction::PanRight, &key) {
        select_in_direction(&mut guard, 1.0, 0.0);
    } else if keybinds.matches_graph(GraphAction::ZoomIn, &key) {
        guard.viewport.zoom_in(config.graf.interaction.zoom_factor);
    } else if keybinds.matches_graph(GraphAction::ZoomOut, &key) {
        guard.viewport.zoom_out(config.graf.interaction.zoom_factor);
    } else if keybinds.matches_graph(GraphAction::OpenNote, &key) {
        if let Some(idx) = guard.selected_node
            && let Some(node) = guard.simulation.get_graph().node_weight(idx)
        {
            return Some(GraphInputAction::OpenFile(node.data.note_id.clone()));
        }
    } else if keybinds.matches_graph(GraphAction::AutoFit, &key) {
        let vp = guard
            .viewport
            .clone()
            .auto_fit_from_graph(guard.simulation.get_graph(), 1.4);
        guard.viewport = vp;
    } else if keybinds.matches_graph(GraphAction::Help, &key) {
        return Some(GraphInputAction::ToggleHelp);
    } else if keybinds.matches_graph(GraphAction::ToggleSearch, &key) {
        return Some(GraphInputAction::ToggleSearch);
    } else if keybinds.matches_graph(GraphAction::ToggleMinimap, &key) {
        return Some(GraphInputAction::ToggleMinimap);
    } else if keybinds.matches_graph(GraphAction::ToggleLegend, &key) {
        return Some(GraphInputAction::ToggleLegend);
    } else if keybinds.matches_graph(GraphAction::ToggleGrid, &key) {
        return Some(GraphInputAction::ToggleGrid);
    } else if keybinds.matches_graph(GraphAction::ToggleStatus, &key) {
        return Some(GraphInputAction::ToggleStatus);
    } else if keybinds.matches_graph(GraphAction::Refresh, &key) {
        return Some(GraphInputAction::Refresh);
    } else if keybinds.matches_graph(GraphAction::ReloadConfig, &key) {
        return Some(GraphInputAction::ReloadConfig);
    } else if keybinds.matches_graph(GraphAction::TogglePreview, &key) {
        return Some(GraphInputAction::TogglePreview);
    }

    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: &ClinConfig,
) -> Option<GraphInputAction> {
    let minimap_area = if config.graf.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
    });

    let inside_area = mouse_event.column >= area.x
        && mouse_event.column < area.x + area.width
        && mouse_event.row >= area.y
        && mouse_event.row < area.y + area.height;

    match mouse_event.kind {
        MouseEventKind::ScrollUp => {
            if !inside_area {
                return None;
            }
            let mut guard = state.write().unwrap_or_else(|e| e.into_inner());
            guard.viewport.zoom_in(config.graf.interaction.zoom_factor);
        }
        MouseEventKind::ScrollDown => {
            if !inside_area {
                return None;
            }
            let mut guard = state.write().unwrap_or_else(|e| e.into_inner());
            guard.viewport.zoom_out(config.graf.interaction.zoom_factor);
        }
        MouseEventKind::Down(MouseButton::Left) => {
            if !inside_area {
                return None;
            }
            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() < 300);

                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(GraphInputAction::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 * 200.0 / (vp.zoom * area.width as f64)
                    * config.graf.interaction.drag_sensitivity;
                let world_dy = dy_row * 200.0 * CELL_ASPECT / (vp.zoom * area.height as f64)
                    * config.graf.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 Some(idx) = guard.selected_node else {
        return;
    };
    let (ox, oy) = {
        let graph = guard.simulation.get_graph();
        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)
}