use std::sync::Arc;
use std::sync::RwLock;
use fdg_sim::petgraph::graph::NodeIndex;
use crossterm::event::KeyCode;
use crate::config::ClinConfig;
use crate::graf::input::GraphMouseState;
use crate::keybinds::Keybinds;
use crate::list_view::PreviewContent;
use crate::markdown::MarkdownRenderer;
use crate::storage::Storage;
pub struct GrafAppState {
pub graph_state: Option<Arc<RwLock<crate::graf::graph::GraphState>>>,
pub graph_kill_tx: Option<std::sync::mpsc::Sender<()>>,
pub graph_mouse_state: GraphMouseState,
pub storage: Storage,
pub config_errors: Vec<String>,
pub search_active: bool,
pub search_query: String,
pub search_results: Vec<(NodeIndex, String)>,
pub search_selected: usize,
pub search_cursor: usize,
pub show_minimap: bool,
pub show_legend: bool,
pub show_grid: bool,
pub show_status_bar: bool,
pub config_reload_msg: Option<String>,
pub preview_enabled: bool,
pub preview_content: Option<PreviewContent>,
pub preview_note_id: Option<String>,
pub app_theme: crate::app_theme::AppThemeColors,
}
impl GrafAppState {
pub fn new(
config: &ClinConfig,
storage: Storage,
config_errors: Vec<String>,
) -> anyhow::Result<Self> {
let graph_state = crate::graf::graph::GraphState::new(&storage, config)?;
let state = Arc::new(RwLock::new(graph_state));
let (kill_tx, kill_rx) = std::sync::mpsc::channel();
crate::graf::physics::start_physics(state.clone(), config, kill_rx);
Ok(Self {
graph_state: Some(state),
graph_kill_tx: Some(kill_tx),
graph_mouse_state: GraphMouseState::default(),
storage,
config_errors,
search_active: false,
search_query: String::new(),
search_results: Vec::new(),
search_selected: 0,
search_cursor: 0,
show_minimap: config.visual.show_minimap,
show_legend: config.visual.show_legend,
show_grid: config.visual.show_grid,
show_status_bar: config.display.show_status_bar,
config_reload_msg: None,
preview_enabled: config.graph_preview_enabled,
preview_content: None,
preview_note_id: None,
app_theme: crate::app_theme::AppThemeColors::from_config(&config.theme),
})
}
pub fn refresh_simulation(&mut self, config: &ClinConfig) {
if let Some(kill_tx) = self.graph_kill_tx.take() {
let _ = kill_tx.send(());
}
if let Ok(graph_state) = crate::graf::graph::GraphState::new(&self.storage, config) {
let state = Arc::new(RwLock::new(graph_state));
let (kill_tx, kill_rx) = std::sync::mpsc::channel();
crate::graf::physics::start_physics(state.clone(), config, kill_rx);
self.graph_state = Some(state);
self.graph_kill_tx = Some(kill_tx);
self.search_results.clear();
self.search_selected = 0;
}
}
pub fn shutdown(&mut self) {
if let Some(kill_tx) = self.graph_kill_tx.take() {
let _ = kill_tx.send(());
}
self.graph_state = None;
}
pub fn poll_renderers(&mut self) -> bool {
if let Some(PreviewContent::Markdown(renderer)) = &mut self.preview_content {
if renderer.poll() {
if !renderer.pages_built() {
let visible = 34u16;
renderer.build_pages(visible, self.app_theme.preview_bg());
}
true
} else {
false
}
} else {
false
}
}
pub fn sync_preview(&mut self, config: &ClinConfig) {
if !self.preview_enabled {
return;
}
let selected_note_id = if let Some(gs) = &self.graph_state {
let guard = gs.read().unwrap_or_else(|e| e.into_inner());
if let Some(idx) = guard.selected_node {
guard
.simulation
.get_graph()
.node_weight(idx)
.map(|n| n.data.note_id.clone())
} else {
None
}
} else {
None
};
if selected_note_id != self.preview_note_id {
self.preview_note_id = selected_note_id;
self.update_preview(config);
}
}
pub fn update_preview(&mut self, config: &ClinConfig) {
if !self.preview_enabled {
self.preview_content = None;
return;
}
let Some(id) = self.preview_note_id.clone() else {
self.preview_content = None;
return;
};
let is_draw = id.ends_with(".draw");
let is_canvas = id.ends_with(".canvas");
let is_clin = id.ends_with(".clin");
if config.preview_encryption && is_clin {
self.preview_content = None;
return;
}
if is_draw {
let path = self.storage.note_path(&id);
match std::fs::read_to_string(path) {
Ok(content) => {
match serde_json::from_str::<crate::draw::state::DrawData>(&content) {
Ok(data) => {
let grid =
crate::snapshot::render_draw_snapshot(&data, &self.app_theme);
self.preview_content = Some(PreviewContent::DrawGrid(grid));
}
Err(_) => {
self.preview_content = None;
}
}
}
Err(_) => {
self.preview_content = None;
}
}
return;
}
if is_canvas {
let path = self.storage.note_path(&id);
match std::fs::read_to_string(path) {
Ok(content) => {
match serde_json::from_str::<crate::pinstar::data::CanvasData>(&content) {
Ok(data) => {
let grid =
crate::snapshot::render_canvas_snapshot(&data, &self.app_theme);
self.preview_content = Some(PreviewContent::CanvasGrid(grid));
}
Err(_) => {
self.preview_content = None;
}
}
}
Err(_) => {
self.preview_content = None;
}
}
return;
}
if let Ok(note) = self.storage.load_note(&id) {
let width = 80u16.saturating_sub(2).max(40);
let mut renderer = MarkdownRenderer::new(width);
renderer.render(¬e.content, width);
self.preview_content = Some(PreviewContent::Markdown(Box::new(renderer)));
} else {
self.preview_content = None;
}
}
}
pub enum EventAction {
Quit,
OpenFile(String),
OpenHelp,
}
pub enum GrafResult {
NoteOpened(String),
Quit,
OpenHelp,
}
pub fn run_graf_view(
terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
storage: crate::storage::Storage,
config: &mut crate::config::ClinConfig,
keybinds: &Keybinds,
) -> anyhow::Result<GrafResult> {
let mut app_state = GrafAppState::new(config, storage, vec![])?;
let mut running = true;
let mut result = GrafResult::Quit;
while running {
app_state.sync_preview(config);
let _ = app_state.poll_renderers();
terminal.draw(|frame| {
crate::graf::ui::draw_ui(frame, &app_state, config, keybinds);
})?;
if crossterm::event::poll(std::time::Duration::from_millis(16))? {
loop {
let ev = crossterm::event::read()?;
if let crossterm::event::Event::Resize(_, _) = ev {
terminal.autoresize()?;
let _ = terminal.clear();
}
if let Some(action) = handle_event(ev, &mut app_state, config, keybinds, terminal)?
{
match action {
EventAction::Quit => {
app_state.shutdown();
result = GrafResult::Quit;
running = false;
}
EventAction::OpenFile(id) => {
app_state.shutdown();
result = GrafResult::NoteOpened(id);
running = false;
}
EventAction::OpenHelp => {
app_state.shutdown();
result = GrafResult::OpenHelp;
running = false;
}
}
}
if !running || !crossterm::event::poll(std::time::Duration::from_millis(0))? {
break;
}
}
}
}
Ok(result)
}
fn handle_event(
ev: crossterm::event::Event,
app_state: &mut GrafAppState,
config: &crate::config::ClinConfig,
keybinds: &Keybinds,
terminal: &ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
) -> anyhow::Result<Option<EventAction>> {
match ev {
crossterm::event::Event::Key(key) => {
if app_state.search_active {
handle_search_keys(app_state, key, config);
return Ok(None);
}
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
match key.code {
KeyCode::Char('h') => {
if let Some(crate::list_view::PreviewContent::Markdown(renderer)) =
&mut app_state.preview_content
{
renderer.prev_page();
return Ok(None);
}
}
KeyCode::Char('l') => {
if let Some(crate::list_view::PreviewContent::Markdown(renderer)) =
&mut app_state.preview_content
{
renderer.next_page();
return Ok(None);
}
}
_ => {}
}
}
if let Some(graph_state) = &app_state.graph_state
&& let Some(action) =
crate::graf::input::handle_graph_keys(graph_state, key, keybinds, config)
{
use crate::graf::input::GraphInputAction;
match action {
GraphInputAction::Quit => return Ok(Some(EventAction::Quit)),
GraphInputAction::ToggleHelp => {
return Ok(Some(EventAction::OpenHelp));
}
GraphInputAction::ToggleSearch => {
app_state.search_active = true;
return Ok(None);
}
GraphInputAction::ToggleMinimap => {
app_state.show_minimap = !app_state.show_minimap;
return Ok(None);
}
GraphInputAction::ToggleLegend => {
app_state.show_legend = !app_state.show_legend;
return Ok(None);
}
GraphInputAction::ToggleGrid => {
app_state.show_grid = !app_state.show_grid;
return Ok(None);
}
GraphInputAction::ToggleStatus => {
app_state.show_status_bar = !app_state.show_status_bar;
return Ok(None);
}
GraphInputAction::OpenFile(path) => {
return Ok(Some(EventAction::OpenFile(path)));
}
GraphInputAction::Refresh => {
app_state.refresh_simulation(config);
return Ok(None);
}
GraphInputAction::ReloadConfig => {
return Ok(None);
}
GraphInputAction::TogglePreview => {
app_state.preview_enabled = !app_state.preview_enabled;
if app_state.preview_enabled {
app_state.sync_preview(config);
} else {
app_state.preview_content = None;
app_state.preview_note_id = None;
}
return Ok(None);
}
}
}
Ok(None)
}
crossterm::event::Event::Mouse(mouse_event) => {
if app_state.search_active {
return Ok(None);
}
if let Some(graph_state) = &app_state.graph_state {
let size = terminal.size().unwrap();
let full_area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
let outer = ratatui::layout::Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([
ratatui::layout::Constraint::Length(1),
ratatui::layout::Constraint::Min(0),
])
.split(full_area);
let content_area = outer[1];
let graph_area = if app_state.preview_enabled {
let (constraints, main_idx) = match config.preview_position {
crate::config::PreviewPosition::Left => (
[
ratatui::layout::Constraint::Ratio(43, 100),
ratatui::layout::Constraint::Length(1),
ratatui::layout::Constraint::Min(0),
],
2,
),
crate::config::PreviewPosition::Right => (
[
ratatui::layout::Constraint::Min(0),
ratatui::layout::Constraint::Length(1),
ratatui::layout::Constraint::Ratio(43, 100),
],
0,
),
};
let chunks = ratatui::layout::Layout::default()
.direction(ratatui::layout::Direction::Horizontal)
.constraints(constraints)
.split(content_area);
chunks[main_idx]
} else {
content_area
};
if let Some(action) = crate::graf::input::handle_graph_mouse(
graph_state,
mouse_event,
graph_area,
&mut app_state.graph_mouse_state,
config,
) {
use crate::graf::input::GraphInputAction;
if let GraphInputAction::OpenFile(path) = action {
return Ok(Some(EventAction::OpenFile(path)));
}
}
}
Ok(None)
}
_ => Ok(None),
}
}
fn handle_search_keys(
app_state: &mut GrafAppState,
key: crossterm::event::KeyEvent,
config: &crate::config::ClinConfig,
) {
use crossterm::event::{KeyCode, KeyModifiers};
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Esc => {
app_state.search_active = false;
app_state.search_query.clear();
app_state.search_results.clear();
app_state.search_selected = 0;
app_state.search_cursor = 0;
}
KeyCode::Enter => {
if let Some(&(idx, _)) = app_state.search_results.get(app_state.search_selected) {
let (nx, ny) = if let Some(graph_state) = &app_state.graph_state {
let guard = graph_state.read().unwrap_or_else(|e| e.into_inner());
let graph = guard.simulation.get_graph();
if let Some(node) = graph.node_weight(idx) {
(node.location.x as f64, node.location.y as f64)
} else {
(0.0, 0.0)
}
} else {
(0.0, 0.0)
};
if let Some(graph_state) = &app_state.graph_state {
let mut guard = graph_state.write().unwrap_or_else(|e| e.into_inner());
guard.selected_node = Some(idx);
guard.viewport.center_on_node(nx as f32, ny as f32);
}
}
app_state.search_active = false;
app_state.search_query.clear();
app_state.search_results.clear();
app_state.search_selected = 0;
app_state.search_cursor = 0;
}
KeyCode::Up => {
if app_state.search_selected > 0 {
app_state.search_selected -= 1;
}
}
KeyCode::Down => {
if !app_state.search_results.is_empty()
&& app_state.search_selected < app_state.search_results.len() - 1
{
app_state.search_selected += 1;
}
}
KeyCode::BackTab | KeyCode::Tab if shift => {
if !app_state.search_results.is_empty() {
app_state.search_selected = app_state
.search_selected
.checked_sub(1)
.unwrap_or(app_state.search_results.len() - 1);
}
}
KeyCode::Tab => {
if !app_state.search_results.is_empty() {
app_state.search_selected =
(app_state.search_selected + 1) % app_state.search_results.len();
}
}
KeyCode::Backspace => {
if app_state.search_cursor > 0 {
let prev = app_state.search_query[..app_state.search_cursor]
.char_indices()
.last()
.map(|(i, _)| i)
.unwrap_or(0);
app_state
.search_query
.replace_range(prev..app_state.search_cursor, "");
app_state.search_cursor = prev;
run_search(app_state, config);
}
}
KeyCode::Delete => {
if app_state.search_cursor < app_state.search_query.len() {
let next = app_state.search_query[app_state.search_cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| app_state.search_cursor + i)
.unwrap_or(app_state.search_query.len());
app_state
.search_query
.replace_range(app_state.search_cursor..next, "");
run_search(app_state, config);
}
}
KeyCode::Left => {
if app_state.search_cursor > 0 {
app_state.search_cursor = app_state.search_query[..app_state.search_cursor]
.char_indices()
.last()
.map(|(i, _)| i)
.unwrap_or(0);
}
}
KeyCode::Right => {
if app_state.search_cursor < app_state.search_query.len() {
app_state.search_cursor = app_state.search_query[app_state.search_cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| app_state.search_cursor + i)
.unwrap_or(app_state.search_query.len());
}
}
KeyCode::Home => {
app_state.search_cursor = 0;
}
KeyCode::End => {
app_state.search_cursor = app_state.search_query.len();
}
KeyCode::Char('h') if ctrl => {
delete_word_back(app_state);
run_search(app_state, config);
}
KeyCode::Char('w') if ctrl => {
delete_word_back(app_state);
run_search(app_state, config);
}
KeyCode::Char('u') if ctrl => {
app_state.search_query.clear();
app_state.search_cursor = 0;
run_search(app_state, config);
}
KeyCode::Char('a') if ctrl => {
app_state.search_cursor = 0;
}
KeyCode::Char('e') if ctrl => {
app_state.search_cursor = app_state.search_query.len();
}
KeyCode::Char(c) if !ctrl => {
const MAX_SEARCH_LEN: usize = 256;
if app_state.search_query.len() < MAX_SEARCH_LEN {
app_state.search_query.insert(app_state.search_cursor, c);
app_state.search_cursor += c.len_utf8();
run_search(app_state, config);
}
}
_ => {}
}
}
fn delete_word_back(app_state: &mut GrafAppState) {
if app_state.search_cursor == 0 {
return;
}
let query = &app_state.search_query[..app_state.search_cursor];
let trimmed = query.trim_end_matches(|c: char| c.is_whitespace());
let cut_to = trimmed
.rfind(|c: char| c.is_whitespace())
.map(|i| i + 1)
.unwrap_or(0);
app_state
.search_query
.replace_range(cut_to..app_state.search_cursor, "");
app_state.search_cursor = cut_to;
}
fn run_search(app_state: &mut GrafAppState, config: &crate::config::ClinConfig) {
if let Some(graph_state) = &app_state.graph_state {
let guard = graph_state.read().unwrap_or_else(|e| e.into_inner());
app_state.search_results = crate::graf::graph::search_nodes(
&guard.simulation,
&app_state.search_query,
config.search.max_results,
);
}
app_state.search_selected = 0;
}