use anyhow::{Context, Result as AnyhowResult};
use clap::Parser;
use crossterm::{
cursor::SetCursorStyle,
event::{
poll as event_poll, read as event_read, DisableBracketedPaste, EnableBracketedPaste,
Event as CrosstermEvent, KeyEvent, KeyEventKind, KeyboardEnhancementFlags, MouseEvent,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use fresh::input::key_translator::KeyTranslator;
#[cfg(target_os = "linux")]
use fresh::services::gpm::{gpm_to_crossterm, GpmClient};
use fresh::services::tracing_setup;
use fresh::{
app::Editor, config, config_io::DirectoryContext, services::release_checker,
services::signal_handler, services::warning_log::WarningLogHandle,
};
use ratatui::Terminal;
use std::{
io::{self, stdout},
path::PathBuf,
time::Duration,
};
#[derive(Parser, Debug)]
#[command(name = "fresh")]
#[command(about = "A terminal text editor with multi-cursor support", long_about = None)]
#[command(version)]
struct Args {
#[arg(value_name = "FILES")]
files: Vec<String>,
#[arg(long)]
stdin: bool,
#[arg(long)]
no_plugins: bool,
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
log_file: Option<PathBuf>,
#[arg(long, value_name = "LOG_FILE")]
event_log: Option<PathBuf>,
#[arg(long)]
no_session: bool,
#[arg(long)]
dump_config: bool,
#[arg(long)]
show_paths: bool,
}
#[derive(Debug)]
struct FileLocation {
path: PathBuf,
line: Option<usize>,
column: Option<usize>,
}
struct IterationOutcome {
loop_result: AnyhowResult<()>,
update_result: Option<release_checker::ReleaseCheckResult>,
restart_dir: Option<PathBuf>,
}
struct SetupState {
config: config::Config,
warning_log_handle: Option<WarningLogHandle>,
terminal: Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
terminal_size: (u16, u16),
file_locations: Vec<FileLocation>,
show_file_explorer: bool,
dir_context: DirectoryContext,
current_working_dir: Option<PathBuf>,
stdin_stream: Option<StdinStreamState>,
key_translator: KeyTranslator,
#[cfg(target_os = "linux")]
gpm_client: Option<GpmClient>,
#[cfg(not(target_os = "linux"))]
gpm_client: Option<()>,
}
#[cfg(unix)]
pub struct StdinStreamState {
pub temp_path: PathBuf,
pub thread_handle: Option<std::thread::JoinHandle<anyhow::Result<()>>>,
}
#[cfg(unix)]
fn start_stdin_streaming() -> AnyhowResult<StdinStreamState> {
use std::fs::File;
use std::os::unix::io::{AsRawFd, FromRawFd};
let stdin_fd = io::stdin().as_raw_fd();
let pipe_fd = unsafe { libc::dup(stdin_fd) };
if pipe_fd == -1 {
anyhow::bail!("Failed to dup stdin: {}", io::Error::last_os_error());
}
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("fresh-stdin-{}.tmp", std::process::id()));
File::create(&temp_path)?;
reopen_stdin_from_tty()?;
tracing::info!("Reopened stdin from /dev/tty for terminal input");
let temp_path_clone = temp_path.clone();
let thread_handle = std::thread::spawn(move || {
use std::io::{Read, Write};
let mut pipe_file = unsafe { File::from_raw_fd(pipe_fd) };
let mut temp_file = std::fs::OpenOptions::new()
.append(true)
.open(&temp_path_clone)?;
const CHUNK_SIZE: usize = 64 * 1024;
let mut buffer = vec![0u8; CHUNK_SIZE];
loop {
let bytes_read = pipe_file.read(&mut buffer)?;
if bytes_read == 0 {
break; }
temp_file.write_all(&buffer[..bytes_read])?;
temp_file.flush()?;
}
tracing::info!("Stdin streaming complete");
Ok(())
});
Ok(StdinStreamState {
temp_path,
thread_handle: Some(thread_handle),
})
}
#[cfg(windows)]
pub struct StdinStreamState {
pub temp_path: PathBuf,
pub thread_handle: Option<std::thread::JoinHandle<anyhow::Result<()>>>,
}
#[cfg(windows)]
fn start_stdin_streaming() -> AnyhowResult<StdinStreamState> {
anyhow::bail!(io::Error::new(
io::ErrorKind::Unsupported,
"Reading from stdin is not yet supported on Windows",
))
}
fn stdin_has_data() -> bool {
use std::io::IsTerminal;
!io::stdin().is_terminal()
}
#[cfg(unix)]
fn reopen_stdin_from_tty() -> AnyhowResult<()> {
use std::fs::File;
use std::os::unix::io::AsRawFd;
let tty = File::open("/dev/tty")?;
let result = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) };
if result == -1 {
anyhow::bail!(io::Error::last_os_error());
}
Ok(())
}
#[cfg(windows)]
fn reopen_stdin_from_tty() -> AnyhowResult<()> {
anyhow::bail!(io::Error::new(
io::ErrorKind::Unsupported,
"Reading from stdin is not yet supported on Windows",
))
}
fn handle_first_run_setup(
editor: &mut Editor,
args: &Args,
file_locations: &[FileLocation],
show_file_explorer: bool,
stdin_stream: &mut Option<StdinStreamState>,
warning_log_handle: &mut Option<WarningLogHandle>,
session_enabled: bool,
) -> AnyhowResult<()> {
if let Some(log_path) = &args.event_log {
tracing::trace!("Event logging enabled: {}", log_path.display());
editor.enable_event_streaming(log_path)?;
}
if let Some(handle) = warning_log_handle.take() {
editor.set_warning_log(handle.receiver, handle.path);
}
if session_enabled {
match editor.try_restore_session() {
Ok(true) => {
tracing::info!("Session restored successfully");
}
Ok(false) => {
tracing::debug!("No previous session found");
}
Err(e) => {
tracing::warn!("Failed to restore session: {}", e);
}
}
}
if let Some(mut stream_state) = stdin_stream.take() {
tracing::info!("Opening stdin buffer from: {:?}", stream_state.temp_path);
editor.open_stdin_buffer(&stream_state.temp_path, stream_state.thread_handle.take())?;
}
for loc in file_locations {
if loc.path.is_dir() {
continue;
}
editor.open_file(&loc.path)?;
if let Some(line) = loc.line {
editor.goto_line_col(line, loc.column);
}
}
if show_file_explorer {
editor.show_file_explorer();
}
if editor.has_recovery_files().unwrap_or(false) {
tracing::info!("Recovery files found from previous session, recovering...");
match editor.recover_all_buffers() {
Ok(count) if count > 0 => {
tracing::info!("Recovered {} buffer(s)", count);
}
Ok(_) => {
tracing::info!("No buffers to recover");
}
Err(e) => {
tracing::warn!("Failed to recover buffers: {}", e);
}
}
}
Ok(())
}
fn parse_file_location(input: &str) -> FileLocation {
use std::path::{Component, Path};
let full_path = PathBuf::from(input);
if full_path.is_file() {
return FileLocation {
path: full_path,
line: None,
column: None,
};
}
let has_prefix = Path::new(input)
.components()
.next()
.map(|c| matches!(c, Component::Prefix(_)))
.unwrap_or(false);
let search_start = if has_prefix {
input.find(':').map(|i| i + 1).unwrap_or(0)
} else {
0
};
let suffix = &input[search_start..];
let parts: Vec<&str> = suffix.rsplitn(3, ':').collect();
match parts.as_slice() {
[maybe_col, maybe_line, rest] => {
if let (Ok(line), Ok(col)) = (maybe_line.parse::<usize>(), maybe_col.parse::<usize>()) {
let path_str = if has_prefix {
format!("{}{}", &input[..search_start], rest)
} else {
rest.to_string()
};
return FileLocation {
path: PathBuf::from(path_str),
line: Some(line),
column: Some(col),
};
}
}
[maybe_line, rest] => {
if let Ok(line) = maybe_line.parse::<usize>() {
let path_str = if has_prefix {
format!("{}{}", &input[..search_start], rest)
} else {
rest.to_string()
};
return FileLocation {
path: PathBuf::from(path_str),
line: Some(line),
column: None,
};
}
}
_ => {}
}
FileLocation {
path: full_path,
line: None,
column: None,
}
}
fn initialize_app(args: &Args) -> AnyhowResult<SetupState> {
let log_file = args
.log_file
.clone()
.unwrap_or_else(fresh::services::log_dirs::main_log_path);
let warning_log_handle = tracing_setup::init_global(&log_file);
fresh::services::log_dirs::cleanup_stale_logs();
tracing::info!("Editor starting");
signal_handler::install_signal_handlers();
tracing::info!("Signal handlers installed");
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic| {
let _ = crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture);
let _ = stdout().execute(DisableBracketedPaste);
let _ = stdout().execute(SetCursorStyle::DefaultUserShape);
fresh::view::theme::Theme::reset_terminal_cursor_color();
let _ = stdout().execute(PopKeyboardEnhancementFlags);
let _ = disable_raw_mode();
let _ = stdout().execute(LeaveAlternateScreen);
original_hook(panic);
}));
let stdin_requested = args.stdin || args.files.iter().any(|f| f == "-");
let stdin_stream = if stdin_requested {
if stdin_has_data() {
tracing::info!("Starting background stdin streaming");
match start_stdin_streaming() {
Ok(stream_state) => {
tracing::info!(
"Stdin streaming started, temp file: {:?}",
stream_state.temp_path
);
Some(stream_state)
}
Err(e) => {
eprintln!("Error: Failed to start stdin streaming: {}", e);
return Err(e);
}
}
} else {
eprintln!("Error: --stdin or \"-\" specified but stdin is a terminal (no piped data)");
anyhow::bail!(io::Error::new(
io::ErrorKind::InvalidInput,
"No data piped to stdin",
));
}
} else {
None
};
let file_locations: Vec<FileLocation> = args
.files
.iter()
.filter(|f| *f != "-")
.map(|f| parse_file_location(f))
.collect();
let mut working_dir = None;
let mut show_file_explorer = false;
if file_locations.len() == 1 {
if let Some(first_loc) = file_locations.first() {
if first_loc.path.is_dir() {
working_dir = Some(first_loc.path.clone());
show_file_explorer = true;
}
}
}
let effective_working_dir = working_dir
.as_ref()
.cloned()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let dir_context = fresh::config_io::DirectoryContext::from_system()?;
let config = if let Some(config_path) = &args.config {
match config::Config::load_from_file(config_path) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!(
"Error: Failed to load config from {}: {}",
config_path.display(),
e
);
anyhow::bail!(io::Error::new(io::ErrorKind::InvalidData, e.to_string()));
}
}
} else {
config::Config::load_with_layers(&dir_context, &effective_working_dir)
};
fresh::i18n::init_with_config(config.locale.as_option());
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let keyboard_flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS;
let _ = stdout().execute(PushKeyboardEnhancementFlags(keyboard_flags));
tracing::info!("Enabled keyboard enhancement flags: {:?}", keyboard_flags);
#[cfg(target_os = "linux")]
let gpm_client = match GpmClient::connect() {
Ok(client) => client,
Err(e) => {
tracing::warn!("Failed to connect to GPM: {}", e);
None
}
};
#[cfg(not(target_os = "linux"))]
let gpm_client: Option<()> = None;
if gpm_client.is_none() {
let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
tracing::info!("Enabled crossterm mouse capture");
} else {
tracing::info!("Using GPM for mouse capture, skipping crossterm mouse protocol");
}
let _ = stdout().execute(EnableBracketedPaste);
tracing::info!("Enabled bracketed paste mode");
let _ = stdout().execute(config.editor.cursor_style.to_crossterm_style());
tracing::info!("Set cursor style to {:?}", config.editor.cursor_style);
let backend = ratatui::backend::CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let size = terminal.size()?;
tracing::info!("Terminal size: {}x{}", size.width, size.height);
let dir_context = DirectoryContext::from_system()?;
let current_working_dir = working_dir;
let key_translator = match KeyTranslator::load_default() {
Ok(translator) => translator,
Err(e) => {
tracing::warn!("Failed to load key calibration: {}", e);
KeyTranslator::new()
}
};
Ok(SetupState {
config,
warning_log_handle,
terminal,
terminal_size: (size.width, size.height),
file_locations,
show_file_explorer,
dir_context,
current_working_dir,
stdin_stream,
key_translator,
gpm_client,
})
}
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
fn run_editor_iteration(
editor: &mut Editor,
session_enabled: bool,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
key_translator: &KeyTranslator,
#[cfg(target_os = "linux")] gpm_client: &Option<GpmClient>,
) -> AnyhowResult<IterationOutcome> {
#[cfg(target_os = "linux")]
let loop_result = run_event_loop(
editor,
terminal,
session_enabled,
key_translator,
gpm_client,
);
#[cfg(not(target_os = "linux"))]
let loop_result = run_event_loop(editor, terminal, session_enabled, key_translator);
if let Err(e) = editor.end_recovery_session() {
tracing::warn!("Failed to end recovery session: {}", e);
}
let update_result = editor.get_update_result().cloned();
let restart_dir = editor.take_restart_dir();
Ok(IterationOutcome {
loop_result,
update_result,
restart_dir,
})
}
fn main() -> AnyhowResult<()> {
let args = Args::parse();
if args.show_paths {
fresh::services::log_dirs::print_all_paths();
return Ok(());
}
if args.dump_config {
let dir_context = fresh::config_io::DirectoryContext::from_system()?;
let working_dir = std::env::current_dir().unwrap_or_default();
let config = if let Some(config_path) = &args.config {
match config::Config::load_from_file(config_path) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!(
"Error: Failed to load config from {}: {}",
config_path.display(),
e
);
anyhow::bail!(
"Failed to load config from {}: {}",
config_path.display(),
e
);
}
}
} else {
config::Config::load_with_layers(&dir_context, &working_dir)
};
match serde_json::to_string_pretty(&config) {
Ok(json) => {
println!("{}", json);
return Ok(());
}
Err(e) => {
eprintln!("Error: Failed to serialize config: {}", e);
anyhow::bail!("Failed to serialize config: {}", e);
}
}
}
let SetupState {
config,
mut warning_log_handle,
mut terminal,
terminal_size,
file_locations,
show_file_explorer,
dir_context,
current_working_dir: initial_working_dir,
mut stdin_stream,
key_translator,
#[cfg(target_os = "linux")]
gpm_client,
#[cfg(not(target_os = "linux"))]
gpm_client,
} = initialize_app(&args).context("Failed to initialize application")?;
let mut current_working_dir = initial_working_dir;
let (terminal_width, terminal_height) = terminal_size;
let mut is_first_run = true;
let mut restore_session_on_restart = false;
let (result, last_update_result) = loop {
let first_run = is_first_run;
let session_enabled = !args.no_session && file_locations.is_empty();
let color_capability = fresh::view::color_support::ColorCapability::detect();
let mut editor = Editor::with_working_dir(
config.clone(),
terminal_width,
terminal_height,
current_working_dir.clone(),
dir_context.clone(),
!args.no_plugins,
color_capability,
)
.context("Failed to create editor instance")?;
#[cfg(target_os = "linux")]
if gpm_client.is_some() {
editor.set_gpm_active(true);
}
if first_run {
handle_first_run_setup(
&mut editor,
&args,
&file_locations,
show_file_explorer,
&mut stdin_stream,
&mut warning_log_handle,
session_enabled,
)
.context("Failed first run setup")?;
} else {
if restore_session_on_restart {
match editor.try_restore_session() {
Ok(true) => {
tracing::info!("Session restored successfully");
}
Ok(false) => {
tracing::debug!("No previous session found");
}
Err(e) => {
tracing::warn!("Failed to restore session: {}", e);
}
}
}
editor.show_file_explorer();
let path = current_working_dir
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| ".".to_string());
editor.set_status_message(fresh::i18n::switched_to_project_message(&path));
}
if let Err(e) = editor.start_recovery_session() {
tracing::warn!("Failed to start recovery session: {}", e);
}
let iteration = run_editor_iteration(
&mut editor,
session_enabled,
&mut terminal,
&key_translator,
#[cfg(target_os = "linux")]
&gpm_client,
)
.context("Editor iteration failed")?;
let update_result = iteration.update_result;
let restart_dir = iteration.restart_dir;
let loop_result = iteration.loop_result;
drop(editor);
if let Some(new_dir) = restart_dir {
tracing::info!(
"Restarting editor with new working directory: {}",
new_dir.display()
);
current_working_dir = Some(new_dir);
is_first_run = false;
restore_session_on_restart = true; terminal
.clear()
.context("Failed to clear terminal for restart")?;
continue;
}
break (loop_result, update_result);
};
let _ = crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture);
let _ = stdout().execute(DisableBracketedPaste);
let _ = stdout().execute(SetCursorStyle::DefaultUserShape);
fresh::view::theme::Theme::reset_terminal_cursor_color();
let _ = stdout().execute(PopKeyboardEnhancementFlags);
disable_raw_mode().context("Failed to disable raw mode")?;
stdout()
.execute(LeaveAlternateScreen)
.context("Failed to leave alternate screen")?;
if let Some(update_result) = last_update_result {
if update_result.update_available {
eprintln!();
eprintln!(
"A new version of fresh is available: {} -> {}",
release_checker::CURRENT_VERSION,
update_result.latest_version
);
if let Some(cmd) = update_result.install_method.update_command() {
eprintln!("Update with: {}", cmd);
} else {
eprintln!(
"Download from: https://github.com/sinelaw/fresh/releases/tag/v{}",
update_result.latest_version
);
}
eprintln!();
}
}
result.context("Editor loop returned an error")
}
#[cfg(target_os = "linux")]
fn run_event_loop(
editor: &mut Editor,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
session_enabled: bool,
key_translator: &KeyTranslator,
gpm_client: &Option<GpmClient>,
) -> AnyhowResult<()> {
run_event_loop_common(
editor,
terminal,
session_enabled,
key_translator,
|timeout| poll_with_gpm(gpm_client.as_ref(), timeout),
)
}
#[cfg(not(target_os = "linux"))]
fn run_event_loop(
editor: &mut Editor,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
session_enabled: bool,
key_translator: &KeyTranslator,
) -> AnyhowResult<()> {
run_event_loop_common(
editor,
terminal,
session_enabled,
key_translator,
|timeout| {
if event_poll(timeout)? {
Ok(Some(event_read()?))
} else {
Ok(None)
}
},
)
}
fn run_event_loop_common<F>(
editor: &mut Editor,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
session_enabled: bool,
_key_translator: &KeyTranslator,
mut poll_event: F,
) -> AnyhowResult<()>
where
F: FnMut(Duration) -> AnyhowResult<Option<CrosstermEvent>>,
{
use std::time::Instant;
const FRAME_DURATION: Duration = Duration::from_millis(16); let mut last_render = Instant::now();
let mut needs_render = true;
let mut pending_event: Option<CrosstermEvent> = None;
loop {
if editor.process_async_messages() {
needs_render = true;
}
if editor.check_mouse_hover_timer() {
needs_render = true;
}
if editor.check_semantic_highlight_timer() {
needs_render = true;
}
if editor.check_warning_log() {
needs_render = true;
}
if editor.poll_stdin_streaming() {
needs_render = true;
}
if let Err(e) = editor.auto_save_dirty_buffers() {
tracing::debug!("Auto-save error: {}", e);
}
if editor.take_full_redraw_request() {
terminal.clear()?;
needs_render = true;
}
if editor.should_quit() {
if session_enabled {
if let Err(e) = editor.save_session() {
tracing::warn!("Failed to save session: {}", e);
} else {
tracing::debug!("Session saved successfully");
}
}
break;
}
if needs_render && last_render.elapsed() >= FRAME_DURATION {
terminal.draw(|frame| editor.render(frame))?;
last_render = Instant::now();
needs_render = false;
}
let event = if let Some(e) = pending_event.take() {
Some(e)
} else {
let timeout = if needs_render {
FRAME_DURATION.saturating_sub(last_render.elapsed())
} else {
Duration::from_millis(50)
};
poll_event(timeout)?
};
let Some(event) = event else { continue };
let (event, next) = coalesce_mouse_moves(event)?;
pending_event = next;
match event {
CrosstermEvent::Key(key_event) => {
if key_event.kind == KeyEventKind::Press {
let translated_event = editor.key_translator().translate(key_event);
handle_key_event(editor, translated_event)?;
needs_render = true;
}
}
CrosstermEvent::Mouse(mouse_event) => {
if handle_mouse_event(editor, mouse_event)? {
needs_render = true;
}
}
CrosstermEvent::Resize(w, h) => {
editor.resize(w, h);
needs_render = true;
}
CrosstermEvent::Paste(text) => {
editor.paste_text(text);
needs_render = true;
}
_ => {}
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn poll_with_gpm(
gpm_client: Option<&GpmClient>,
timeout: Duration,
) -> AnyhowResult<Option<CrosstermEvent>> {
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
use std::os::unix::io::{AsRawFd, BorrowedFd};
let Some(gpm) = gpm_client else {
return if event_poll(timeout)? {
Ok(Some(event_read()?))
} else {
Ok(None)
};
};
let stdin_fd = std::io::stdin().as_raw_fd();
let gpm_fd = gpm.fd();
tracing::trace!("GPM poll: stdin_fd={}, gpm_fd={}", stdin_fd, gpm_fd);
let stdin_borrowed = unsafe { BorrowedFd::borrow_raw(stdin_fd) };
let gpm_borrowed = unsafe { BorrowedFd::borrow_raw(gpm_fd) };
let mut poll_fds = [
PollFd::new(stdin_borrowed, PollFlags::POLLIN),
PollFd::new(gpm_borrowed, PollFlags::POLLIN),
];
let timeout_ms = timeout.as_millis().min(u16::MAX as u128) as u16;
let poll_timeout = PollTimeout::from(timeout_ms);
let ready = poll(&mut poll_fds, poll_timeout)?;
if ready == 0 {
return Ok(None);
}
let stdin_revents = poll_fds[0].revents();
let gpm_revents = poll_fds[1].revents();
tracing::trace!(
"GPM poll: ready={}, stdin_revents={:?}, gpm_revents={:?}",
ready,
stdin_revents,
gpm_revents
);
if gpm_revents.is_some_and(|r| r.contains(PollFlags::POLLIN)) {
tracing::trace!("GPM poll: GPM fd has data, reading event...");
match gpm.read_event() {
Ok(Some(gpm_event)) => {
tracing::trace!(
"GPM event received: x={}, y={}, buttons={}, type=0x{:x}",
gpm_event.x,
gpm_event.y,
gpm_event.buttons.0,
gpm_event.event_type
);
if let Some(mouse_event) = gpm_to_crossterm(&gpm_event) {
tracing::trace!("GPM event converted to crossterm: {:?}", mouse_event);
return Ok(Some(CrosstermEvent::Mouse(mouse_event)));
} else {
tracing::debug!("GPM event could not be converted to crossterm event");
}
}
Ok(None) => {
tracing::trace!("GPM poll: read_event returned None");
}
Err(e) => {
tracing::warn!("GPM poll: read_event error: {}", e);
}
}
}
if stdin_revents.is_some_and(|r| r.contains(PollFlags::POLLIN)) {
if event_poll(Duration::ZERO)? {
return Ok(Some(event_read()?));
}
}
Ok(None)
}
fn handle_key_event(editor: &mut Editor, key_event: KeyEvent) -> AnyhowResult<()> {
tracing::trace!(
"Key event received: code={:?}, modifiers={:?}, kind={:?}, state={:?}",
key_event.code,
key_event.modifiers,
key_event.kind,
key_event.state
);
let key_code = format!("{:?}", key_event.code);
let modifiers = format!("{:?}", key_event.modifiers);
editor.log_keystroke(&key_code, &modifiers);
editor.handle_key(key_event.code, key_event.modifiers)?;
Ok(())
}
fn handle_mouse_event(editor: &mut Editor, mouse_event: MouseEvent) -> AnyhowResult<bool> {
tracing::trace!(
"Mouse event received: kind={:?}, column={}, row={}, modifiers={:?}",
mouse_event.kind,
mouse_event.column,
mouse_event.row,
mouse_event.modifiers
);
editor
.handle_mouse(mouse_event)
.context("Failed to handle mouse event")
}
fn coalesce_mouse_moves(
event: CrosstermEvent,
) -> AnyhowResult<(CrosstermEvent, Option<CrosstermEvent>)> {
use crossterm::event::MouseEventKind;
if !matches!(&event, CrosstermEvent::Mouse(m) if m.kind == MouseEventKind::Moved) {
return Ok((event, None));
}
let mut latest = event;
while event_poll(Duration::ZERO)? {
let next = event_read()?;
if matches!(&next, CrosstermEvent::Mouse(m) if m.kind == MouseEventKind::Moved) {
latest = next; } else {
return Ok((latest, Some(next))); }
}
Ok((latest, None))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_file_location_simple_path() {
let loc = parse_file_location("foo.txt");
assert_eq!(loc.path, PathBuf::from("foo.txt"));
assert_eq!(loc.line, None);
assert_eq!(loc.column, None);
}
#[test]
fn test_parse_multiple_files() {
let inputs = vec!["file1.txt", "sub/file2.rs:10", "file3.cpp:20:5"];
let locs: Vec<FileLocation> = inputs.iter().map(|i| parse_file_location(i)).collect();
assert_eq!(locs.len(), 3);
assert_eq!(locs[0].path, PathBuf::from("file1.txt"));
assert_eq!(locs[0].line, None);
assert_eq!(locs[0].column, None);
assert_eq!(locs[1].path, PathBuf::from("sub/file2.rs"));
assert_eq!(locs[1].line, Some(10));
assert_eq!(locs[1].column, None);
assert_eq!(locs[2].path, PathBuf::from("file3.cpp"));
assert_eq!(locs[2].line, Some(20));
assert_eq!(locs[2].column, Some(5));
}
#[test]
fn test_parse_file_location_with_line() {
let loc = parse_file_location("foo.txt:42");
assert_eq!(loc.path, PathBuf::from("foo.txt"));
assert_eq!(loc.line, Some(42));
assert_eq!(loc.column, None);
}
#[test]
fn test_parse_file_location_with_line_and_col() {
let loc = parse_file_location("foo.txt:42:10");
assert_eq!(loc.path, PathBuf::from("foo.txt"));
assert_eq!(loc.line, Some(42));
assert_eq!(loc.column, Some(10));
}
#[test]
fn test_parse_file_location_absolute_path() {
let loc = parse_file_location("/home/user/foo.txt:100:5");
assert_eq!(loc.path, PathBuf::from("/home/user/foo.txt"));
assert_eq!(loc.line, Some(100));
assert_eq!(loc.column, Some(5));
}
#[test]
fn test_parse_file_location_no_numbers_after_colon() {
let loc = parse_file_location("foo:bar");
assert_eq!(loc.path, PathBuf::from("foo:bar"));
assert_eq!(loc.line, None);
assert_eq!(loc.column, None);
}
#[test]
fn test_parse_file_location_mixed_suffix() {
let loc = parse_file_location("foo:10:bar");
assert_eq!(loc.path, PathBuf::from("foo:10:bar"));
assert_eq!(loc.line, None);
assert_eq!(loc.column, None);
}
#[test]
fn test_parse_file_location_line_only_not_col() {
let loc = parse_file_location("foo:bar:10");
assert_eq!(loc.path, PathBuf::from("foo:bar:10"));
assert_eq!(loc.line, None);
assert_eq!(loc.column, None);
}
}
#[cfg(all(test, not(windows)))]
mod proptests {
use super::*;
use proptest::prelude::*;
fn unix_path_strategy() -> impl Strategy<Value = String> {
prop::collection::vec("[a-zA-Z0-9._-]+", 1..5).prop_map(|components| components.join("/"))
}
proptest! {
#[test]
fn roundtrip_line_col(
path in unix_path_strategy(),
line in 1usize..10000,
col in 1usize..1000
) {
let input = format!("{}:{}:{}", path, line, col);
let loc = parse_file_location(&input);
prop_assert_eq!(loc.path, PathBuf::from(&path));
prop_assert_eq!(loc.line, Some(line));
prop_assert_eq!(loc.column, Some(col));
}
#[test]
fn roundtrip_line_only(
path in unix_path_strategy(),
line in 1usize..10000
) {
let input = format!("{}:{}", path, line);
let loc = parse_file_location(&input);
prop_assert_eq!(loc.path, PathBuf::from(&path));
prop_assert_eq!(loc.line, Some(line));
prop_assert_eq!(loc.column, None);
}
#[test]
fn path_without_numbers_unchanged(
path in unix_path_strategy()
) {
let loc = parse_file_location(&path);
prop_assert_eq!(loc.path, PathBuf::from(&path));
prop_assert_eq!(loc.line, None);
prop_assert_eq!(loc.column, None);
}
#[test]
fn parsed_values_match_input(
path in unix_path_strategy(),
line in 0usize..10000,
col in 0usize..1000
) {
let input = format!("{}:{}:{}", path, line, col);
let loc = parse_file_location(&input);
prop_assert_eq!(loc.line, Some(line));
prop_assert_eq!(loc.column, Some(col));
}
}
}