mod app;
mod help_text;
mod image_cache;
mod interactive;
mod kitty_animation;
#[cfg(all(feature = "mermaid", unix))]
mod mermaid;
mod syntax;
pub mod terminal_compat;
pub mod theme;
pub mod tty; mod ui;
mod watcher;
pub use app::{ActionResult, App};
pub use interactive::InteractiveState;
pub use terminal_compat::{ColorMode, TerminalCapabilities};
pub use theme::ThemeName;
use crate::keybindings::Action;
use color_eyre::Result;
use crossterm::ExecutableCommand;
use crossterm::event::{KeyCode, MouseEvent, MouseEventKind};
use crossterm::terminal::{
BeginSynchronizedUpdate, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen,
};
use opensesame::{Editor, EditorConfig};
use ratatui::DefaultTerminal;
use std::io::stdout;
use std::path::Path;
use std::time::Duration;
fn run_editor(
terminal: &mut DefaultTerminal,
file: &Path,
line: Option<u32>,
editor_config: &EditorConfig,
) -> Result<()> {
stdout().execute(LeaveAlternateScreen)?;
tty::suspend_raw_mode()?;
let mut builder = Editor::builder()
.file(file)
.with_config(editor_config.clone());
if let Some(l) = line {
builder = builder.line(l);
}
let result = builder.open();
stdout().execute(EnterAlternateScreen)?;
tty::resume_raw_mode()?;
terminal.clear()?;
result.map_err(|e| color_eyre::eyre::eyre!("{}", e))
}
pub fn run(terminal: &mut DefaultTerminal, app: App) -> Result<()> {
let mut app = app;
if app.startup_needs_file_picker {
app.enter_file_picker();
}
let mut file_watcher = watcher::FileWatcher::new().ok();
if let Some(ref mut watcher) = file_watcher {
let _ = watcher.watch(&app.current_file_path);
}
let mut needs_redraw = true;
loop {
if needs_redraw {
let use_sync = app.is_image_modal_open()
&& app.image_modal.gif_frames.len() > 1
&& !app.has_kitty_animation();
if use_sync {
let _ = stdout().execute(BeginSynchronizedUpdate);
}
terminal.draw(|frame| ui::render(frame, &mut app))?;
if use_sync {
let _ = stdout().execute(EndSynchronizedUpdate);
}
#[cfg(all(feature = "mermaid", unix))]
if app.mermaid_needs_reindex {
needs_redraw = true;
continue;
}
needs_redraw = false;
}
if app.file_path_changed {
app.file_path_changed = false;
if let Some(ref mut watcher) = file_watcher {
let _ = watcher.watch(&app.current_file_path);
}
}
if let Some(file_path) = app.pending_editor_file.take() {
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file");
let editor_config = app.editor_config();
match run_editor(terminal, &file_path, None, &editor_config) {
Ok(_) => {
app.status_message = Some(format!("✓ Opened {} in editor", filename));
}
Err(e) => {
app.status_message = Some(format!("✗ Failed to open {}: {}", filename, e));
}
}
needs_redraw = true;
continue; }
let poll_timeout = app
.time_until_next_frame()
.unwrap_or(Duration::from_millis(100));
let event_ready = tty::poll_event(poll_timeout)?;
if app.suppress_file_watch {
app.suppress_file_watch = false;
if let Some(ref mut watcher) = file_watcher {
watcher.check_for_changes(); }
} else if let Some(ref mut watcher) = file_watcher
&& watcher.check_for_changes()
{
let was_interactive = app.mode == app::AppMode::Interactive;
let saved_scroll = app.content_scroll;
let saved_element_idx = app.interactive_state.current_index;
if let Err(e) = app.reload_current_file() {
app.status_message = Some(format!("✗ Reload failed: {}", e));
} else {
if was_interactive {
app.reindex_interactive_elements();
if let Some(idx) = saved_element_idx
&& idx < app.interactive_state.elements.len()
{
app.interactive_state.current_index = Some(idx);
}
}
app.content_scroll = saved_scroll.min(app.max_content_scroll());
app.content_scroll_state = app
.content_scroll_state
.position(app.content_scroll as usize);
app.sync_previous_selection();
app.status_message = Some("↻ File reloaded (external change)".to_string());
}
needs_redraw = true;
}
if !event_ready {
if app.is_image_modal_open() && app.image_modal.gif_frames.len() > 1 {
needs_redraw = true;
}
continue;
}
needs_redraw = true;
let event = tty::read_event()?;
if let Some(mouse) = event.as_mouse_event() {
handle_mouse(&mut app, mouse);
continue;
}
if let Some(key) = event.as_key_press_event() {
if app.is_image_modal_open() {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.close_image_modal();
}
KeyCode::Left | KeyCode::Char('h') => {
app.modal_prev_frame();
}
KeyCode::Right | KeyCode::Char('l') => {
app.modal_next_frame();
}
KeyCode::Char(' ') => {
app.modal_toggle_animation();
}
_ => {}
}
continue;
}
let handled = handle_text_input(&mut app, key.code, key.modifiers);
if !handled {
let digit_handled = if let KeyCode::Char(c) = key.code {
if c.is_ascii_digit()
&& key.modifiers.is_empty()
&& matches!(app.mode, app::AppMode::Normal | app::AppMode::Interactive)
{
if c == '0' && !app.has_count() {
false } else {
app.accumulate_count_digit(c)
}
} else {
false
}
} else {
false
};
if !digit_handled {
if let Some(action) = app.get_action_for_key(key.code, key.modifiers) {
if action == Action::ConfirmAction
&& app.mode == app::AppMode::CommandPalette
{
if app.execute_selected_command() {
return Ok(()); }
} else {
match app.execute_action(action) {
ActionResult::Quit => return Ok(()),
ActionResult::RunEditor(path, line) => {
let editor_config = app.editor_config();
match run_editor(terminal, &path, line, &editor_config) {
Ok(_) => {
if let Err(e) = app.reload_current_file() {
app.status_message =
Some(format!("✗ Failed to reload: {}", e));
} else {
app.status_message = Some(
"✓ File reloaded after editing".to_string(),
);
}
app.update_content_metrics();
}
Err(e) => {
app.status_message =
Some(format!("✗ Editor failed: {}", e));
}
}
}
ActionResult::Redraw => {
terminal.clear()?;
}
ActionResult::Continue => {}
}
}
} else {
app.clear_count();
}
}
}
}
}
}
fn handle_mouse(app: &mut App, mouse: MouseEvent) {
use crate::keybindings::Action;
let action = match mouse.kind {
MouseEventKind::ScrollDown => Some(Action::Next),
MouseEventKind::ScrollUp => Some(Action::Previous),
_ => None,
};
if let Some(a) = action {
let _ = app.execute_action(a);
}
}
fn handle_text_input(
app: &mut App,
code: KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> bool {
if app.show_search && app.outline_search_active {
match code {
KeyCode::Char('u') if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => {
app.search_query.clear();
app.filter_outline();
return true;
}
KeyCode::Char(c) => {
app.search_input(c);
return true;
}
_ => {}
}
}
if app.mode == app::AppMode::DocSearch && app.doc_search.active {
match code {
KeyCode::Char('u') if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => {
app.doc_search.query.clear();
app.update_doc_search_matches();
return true;
}
KeyCode::Char(c) => {
app.doc_search_input(c);
return true;
}
_ => {}
}
}
if app.mode == app::AppMode::LinkFollow
&& app.link_picker.active
&& let KeyCode::Char(c) = code
{
app.link_search_push(c);
return true;
}
if ((app.mode == app::AppMode::FilePicker && app.file_picker.active)
|| app.mode == app::AppMode::FileSearch)
&& let KeyCode::Char(c) = code
{
app.file_search_push(c);
return true;
}
if app.mode == app::AppMode::CommandPalette
&& let KeyCode::Char(c) = code
{
app.command_palette_input(c);
return true;
}
if app.mode == app::AppMode::CellEdit
&& let KeyCode::Char(c) = code
{
app.cell_edit_value.push(c);
return true;
}
false
}