use anyhow::Result;
use hjkl_buffer::Buffer;
use hjkl_engine::{BufferEdit, Host};
use hjkl_engine::{CursorShape, Editor, Options, VimMode};
use hjkl_form::TextFieldEditor;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime};
use crate::host::TuiHost;
use crate::syntax::{self, BufferId, SyntaxLayer};
mod buffer_ops;
mod event_loop;
mod ex_dispatch;
mod picker_glue;
mod prompt;
mod syntax_glue;
#[cfg(test)]
mod tests;
pub const STATUS_LINE_HEIGHT: u16 = 1;
pub const BUFFER_LINE_HEIGHT: u16 = 1;
fn canon_for_match(p: &std::path::Path) -> PathBuf {
if let Ok(c) = std::fs::canonicalize(p) {
return c;
}
if p.is_absolute() {
p.to_path_buf()
} else if let Ok(cwd) = std::env::current_dir() {
cwd.join(p)
} else {
p.to_path_buf()
}
}
fn buffer_signature(editor: &Editor<Buffer, TuiHost>) -> (u64, usize) {
let mut hasher = DefaultHasher::new();
let mut len = 0usize;
let lines = editor.buffer().lines();
for (i, l) in lines.iter().enumerate() {
if i > 0 {
b'\n'.hash(&mut hasher);
len += 1;
}
l.hash(&mut hasher);
len += l.len();
}
(hasher.finish(), len)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiskState {
Synced,
ChangedOnDisk,
DeletedOnDisk,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchDir {
Forward,
Backward,
}
pub struct BufferSlot {
pub buffer_id: BufferId,
pub editor: Editor<Buffer, TuiHost>,
pub filename: Option<PathBuf>,
pub dirty: bool,
pub is_new_file: bool,
pub is_untracked: bool,
pub diag_signs: Vec<hjkl_buffer::Sign>,
pub git_signs: Vec<hjkl_buffer::Sign>,
last_git_dirty_gen: Option<u64>,
last_git_refresh_at: Instant,
last_recompute_at: Instant,
last_recompute_key: Option<(u64, usize, usize)>,
saved_hash: u64,
saved_len: usize,
pub disk_mtime: Option<SystemTime>,
pub disk_len: Option<u64>,
pub disk_state: DiskState,
}
impl BufferSlot {
fn snapshot_saved(&mut self) {
let (h, l) = buffer_signature(&self.editor);
self.saved_hash = h;
self.saved_len = l;
self.dirty = false;
}
fn refresh_dirty_against_saved(&mut self) -> u128 {
let t = std::time::Instant::now();
let (h, l) = buffer_signature(&self.editor);
let elapsed = t.elapsed().as_micros();
self.dirty = h != self.saved_hash || l != self.saved_len;
elapsed
}
}
pub struct App {
slots: Vec<BufferSlot>,
active: usize,
next_buffer_id: BufferId,
pub prev_active: Option<usize>,
pub exit_requested: bool,
pub status_message: Option<String>,
pub info_popup: Option<String>,
pub command_field: Option<TextFieldEditor>,
pub search_field: Option<TextFieldEditor>,
pub picker: Option<crate::picker::Picker>,
pub pending_leader: bool,
pub pending_git: bool,
pub pending_buffer_motion: Option<char>,
pub search_dir: SearchDir,
last_cursor_shape: CursorShape,
syntax: SyntaxLayer,
pub directory: std::sync::Arc<crate::lang::LanguageDirectory>,
pub theme: crate::theme::AppTheme,
pub perf_overlay: bool,
pub last_recompute_us: u128,
pub last_install_us: u128,
pub last_signature_us: u128,
pub last_git_us: u128,
pub last_perf: crate::syntax::PerfBreakdown,
pub recompute_hits: u64,
pub recompute_throttled: u64,
pub recompute_runs: u64,
pub config: crate::config::Config,
pub start_screen: Option<crate::start_screen::StartScreen>,
}
fn prompt_cursor_shape(field: &hjkl_form::TextFieldEditor) -> CursorShape {
match field.vim_mode() {
hjkl_form::VimMode::Insert => CursorShape::Bar,
_ => CursorShape::Block,
}
}
pub(super) fn build_slot(
syntax: &mut SyntaxLayer,
buffer_id: BufferId,
path: Option<PathBuf>,
config: &crate::config::Config,
) -> Result<BufferSlot, String> {
let mut buffer = Buffer::new();
let mut is_new_file = false;
let mut disk_mtime: Option<SystemTime> = None;
let mut disk_len: Option<u64> = None;
if let Some(ref p) = path {
match std::fs::read_to_string(p) {
Ok(content) => {
if let Ok(meta) = std::fs::metadata(p) {
disk_mtime = meta.modified().ok();
disk_len = Some(meta.len());
}
let content = content.strip_suffix('\n').unwrap_or(&content);
BufferEdit::replace_all(&mut buffer, content);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
is_new_file = true;
}
Err(e) => return Err(format!("E484: Can't open file {}: {e}", p.display())),
}
}
let host = TuiHost::new();
let mut ec_opts = Options {
expandtab: config.editor.expandtab,
tabstop: config.editor.tab_width as u32,
shiftwidth: config.editor.tab_width as u32,
softtabstop: config.editor.tab_width as u32,
..Options::default()
};
if let Some(ref p) = path {
crate::editorconfig::overlay_for_path(&mut ec_opts, p);
}
let mut editor = Editor::new(buffer, host, ec_opts);
if let Ok(size) = crossterm::terminal::size() {
let vp = editor.host_mut().viewport_mut();
vp.width = size.0;
vp.height = size.1.saturating_sub(STATUS_LINE_HEIGHT);
}
if let Some(ref p) = path {
syntax.set_language_for_path(buffer_id, p);
}
let (vp_top, vp_height) = {
let vp = editor.host().viewport();
(vp.top_row, vp.height as usize)
};
if let Some(out) = syntax.preview_render(buffer_id, editor.buffer(), vp_top, vp_height) {
editor.install_ratatui_syntax_spans(out.spans);
}
syntax.submit_render(buffer_id, editor.buffer(), vp_top, vp_height);
let initial_dg = editor.buffer().dirty_gen();
let (key, signs) = if let Some(out) = syntax.wait_for_initial_result(Duration::from_millis(150))
{
let k = out.key;
editor.install_ratatui_syntax_spans(out.spans);
(Some(k), out.signs)
} else {
(Some((initial_dg, vp_top, vp_height)), Vec::new())
};
let _ = editor.take_content_edits();
let _ = editor.take_content_reset();
let mut slot = BufferSlot {
buffer_id,
editor,
filename: path,
dirty: false,
is_new_file,
is_untracked: false,
diag_signs: signs,
git_signs: Vec::new(),
last_git_dirty_gen: None,
last_git_refresh_at: Instant::now(),
last_recompute_at: Instant::now() - Duration::from_secs(1),
last_recompute_key: key,
saved_hash: 0,
saved_len: 0,
disk_mtime,
disk_len,
disk_state: DiskState::Synced,
};
slot.snapshot_saved();
Ok(slot)
}
impl App {
pub fn active(&self) -> &BufferSlot {
&self.slots[self.active]
}
pub fn active_mut(&mut self) -> &mut BufferSlot {
&mut self.slots[self.active]
}
pub fn slots(&self) -> &[BufferSlot] {
&self.slots
}
pub fn active_index(&self) -> usize {
self.active
}
pub fn new(
filename: Option<PathBuf>,
readonly: bool,
goto_line: Option<usize>,
search_pattern: Option<String>,
) -> Result<Self> {
let theme = crate::theme::AppTheme::default_dark();
let directory = std::sync::Arc::new(crate::lang::LanguageDirectory::new()?);
let mut syntax = syntax::layer_with_theme(theme.syntax.clone(), directory.clone());
let buffer_id: BufferId = 0;
let bootstrap_config = crate::config::Config::default();
let no_file = filename.is_none();
let mut slot = build_slot(&mut syntax, buffer_id, filename, &bootstrap_config)
.map_err(|s| anyhow::anyhow!(s))?;
if readonly {
slot.editor.apply_options(&Options {
readonly: true,
..Options::default()
});
}
if let Some(n) = goto_line {
slot.editor.goto_line(n);
}
if let Some(pat) = search_pattern {
match regex::Regex::new(&pat) {
Ok(re) => {
slot.editor.set_search_pattern(Some(re));
slot.editor.search_advance_forward(false);
}
Err(e) => {
eprintln!("hjkl: bad search pattern: {e}");
}
}
}
let start_screen = if no_file {
Some(crate::start_screen::StartScreen::new())
} else {
None
};
Ok(Self {
slots: vec![slot],
active: 0,
next_buffer_id: 1,
prev_active: None,
exit_requested: false,
status_message: None,
info_popup: None,
command_field: None,
search_field: None,
picker: None,
pending_leader: false,
pending_git: false,
pending_buffer_motion: None,
search_dir: SearchDir::Forward,
last_cursor_shape: CursorShape::Block,
syntax,
directory,
theme,
perf_overlay: false,
last_recompute_us: 0,
last_install_us: 0,
last_signature_us: 0,
last_git_us: 0,
last_perf: crate::syntax::PerfBreakdown::default(),
recompute_hits: 0,
recompute_throttled: 0,
recompute_runs: 0,
config: crate::config::Config::default(),
start_screen,
})
}
pub fn with_config(mut self, config: crate::config::Config) -> Self {
self.config = config;
for slot in &mut self.slots {
let was_readonly = slot.editor.is_readonly();
let mut opts = Options {
expandtab: self.config.editor.expandtab,
tabstop: self.config.editor.tab_width as u32,
shiftwidth: self.config.editor.tab_width as u32,
softtabstop: self.config.editor.tab_width as u32,
readonly: was_readonly,
..Options::default()
};
if let Some(p) = slot.filename.as_ref() {
crate::editorconfig::overlay_for_path(&mut opts, p);
}
slot.editor.apply_options(&opts);
}
self
}
pub fn mode_label(&self) -> &'static str {
if self.start_screen.is_some() {
return "START";
}
match self.active().editor.vim_mode() {
VimMode::Normal => "NORMAL",
VimMode::Insert => "INSERT",
VimMode::Visual => "VISUAL",
VimMode::VisualLine => "VISUAL LINE",
VimMode::VisualBlock => "VISUAL BLOCK",
}
}
pub fn open_extra(&mut self, path: PathBuf) -> Result<(), String> {
self.open_new_slot(path).map(|_| ())
}
}