mod app;
mod edits;
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,
mouse_captured: bool,
) -> Result<()> {
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
stdout().execute(LeaveAlternateScreen)?;
if mouse_captured {
let _ = stdout().execute(DisableMouseCapture);
}
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)?;
if mouse_captured {
let _ = stdout().execute(EnableMouseCapture);
}
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,
app.mouse_capture,
) {
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()
{
if app.has_unsaved_changes {
app.status_message = Some(
"⚠ File changed on disk — reload skipped to protect unsaved edits (save or undo first)"
.to_string(),
);
needs_redraw = true;
} else {
let was_interactive = app.mode == app::AppMode::Interactive;
let saved_scroll = app.content_scroll;
let saved_element_idx = app.interactive_state.current_index;
match app.reload_current_file() {
Err(e) => {
app.status_message = Some(format!("✗ Reload failed: {}", e));
needs_redraw = true;
}
Ok(false) => {}
Ok(true) => {
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;
}
app.dismiss_sticky_status_message();
let prev_status = app.status_message.clone();
let prev_status_time = app.status_message_time;
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) {
{
let result = if action == Action::ConfirmAction
&& app.mode == app::AppMode::CommandPalette
{
app.execute_selected_command()
} else {
app.execute_action(action)
};
match result {
ActionResult::Quit => return Ok(()),
ActionResult::RunEditor(path, line) => {
let editor_config = app.editor_config();
match run_editor(
terminal,
&path,
line,
&editor_config,
app.mouse_capture,
) {
Ok(_) => {
let had_pending = app.has_unsaved_changes;
match app.reload_current_file() {
Err(e) => {
app.status_message =
Some(format!("✗ Failed to reload: {}", e));
}
Ok(reloaded) => {
if reloaded && had_pending {
app.discard_pending_edits();
app.status_message = Some(
"✓ File reloaded after editing (buffered edits discarded)"
.to_string(),
);
} else if reloaded {
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();
}
}
}
if app.status_message != prev_status && app.status_message_time == prev_status_time {
app.status_message_time = None;
}
}
}
}
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 {
use crate::tui::app::TextInputEdit;
use crossterm::event::KeyModifiers;
let chorded =
modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
let edit = match code {
KeyCode::Char('u') if modifiers == KeyModifiers::CONTROL => TextInputEdit::Clear,
KeyCode::Char('w') if modifiers == KeyModifiers::CONTROL => TextInputEdit::DeleteWord,
KeyCode::Char(c) if !chorded => TextInputEdit::Insert(c),
_ => return false,
};
app.apply_text_input_edit(edit)
}