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::HashMap;
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;
pub mod lsp_glue;
mod picker_glue;
mod prompt;
mod syntax_glue;
#[cfg(test)]
mod tests;
pub mod window;
use crate::completion::Completion;
pub const STATUS_LINE_HEIGHT: u16 = 1;
const GRAMMAR_ERR_TTL: Duration = Duration::from_secs(5);
#[derive(Clone)]
pub(crate) struct GrammarLoadError {
pub name: String,
pub message: String,
pub at: Instant,
}
impl GrammarLoadError {
pub fn is_expired(&self) -> bool {
self.at.elapsed() >= GRAMMAR_ERR_TTL
}
}
pub const BUFFER_LINE_HEIGHT: u16 = 1;
pub const TAB_BAR_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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DiagSeverity {
Error = 1,
Warning = 2,
Info = 3,
Hint = 4,
}
#[derive(Debug, Clone)]
pub struct LspDiag {
pub start_row: usize,
pub start_col: usize,
pub end_row: usize,
pub end_col: usize,
pub severity: DiagSeverity,
pub message: String,
pub source: Option<String>,
pub code: Option<String>,
}
pub struct LspServerInfo {
pub initialized: bool,
pub capabilities: serde_json::Value,
}
#[derive(Debug, Clone)]
pub enum LspPendingRequest {
GotoDefinition {
buffer_id: hjkl_lsp::BufferId,
origin: (usize, usize),
},
GotoDeclaration {
buffer_id: hjkl_lsp::BufferId,
origin: (usize, usize),
},
GotoTypeDefinition {
buffer_id: hjkl_lsp::BufferId,
origin: (usize, usize),
},
GotoImplementation {
buffer_id: hjkl_lsp::BufferId,
origin: (usize, usize),
},
GotoReferences {
buffer_id: hjkl_lsp::BufferId,
origin: (usize, usize),
},
Hover {
buffer_id: hjkl_lsp::BufferId,
origin: (usize, usize),
},
Completion {
buffer_id: hjkl_lsp::BufferId,
anchor_row: usize,
anchor_col: usize,
},
CodeAction {
buffer_id: hjkl_lsp::BufferId,
anchor_row: usize,
anchor_col: usize,
},
Rename {
buffer_id: hjkl_lsp::BufferId,
anchor_row: usize,
anchor_col: usize,
new_name: String,
},
Format {
buffer_id: hjkl_lsp::BufferId,
range: Option<(usize, usize, usize, usize)>,
},
}
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 diag_signs_lsp: Vec<hjkl_buffer::Sign>,
pub lsp_diags: Vec<LspDiag>,
pub(crate) last_lsp_dirty_gen: Option<u64>,
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>,
pub windows: Vec<Option<window::Window>>,
pub tabs: Vec<window::Tab>,
pub active_tab: usize,
next_window_id: window::WindowId,
pub pending_window_motion: bool,
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_lsp: Option<char>,
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(crate) preview_highlighters:
std::sync::Mutex<std::collections::HashMap<String, hjkl_bonsai::Highlighter>>,
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>,
pub(crate) grammar_load_error: Option<GrammarLoadError>,
pub lsp: Option<hjkl_lsp::LspManager>,
pub lsp_state: HashMap<hjkl_lsp::ServerKey, LspServerInfo>,
pub lsp_next_request_id: i64,
pub lsp_pending: HashMap<i64, LspPendingRequest>,
pub completion: Option<Completion>,
pub pending_code_actions: Vec<lsp_types::CodeActionOrCommand>,
pub pending_ctrl_x: bool,
pub mouse_enabled: bool,
}
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 viewport_height = size.1.saturating_sub(STATUS_LINE_HEIGHT);
let vp = editor.host_mut().viewport_mut();
vp.width = size.0;
vp.height = viewport_height;
editor.set_viewport_height(viewport_height);
}
if let Some(ref p) = path {
let outcome = syntax.set_language_for_path(buffer_id, p);
let _ = outcome; }
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,
diag_signs_lsp: Vec::new(),
lsp_diags: Vec::new(),
last_lsp_dirty_gen: None,
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 layout(&self) -> &window::LayoutTree {
&self.tabs[self.active_tab].layout
}
pub fn layout_mut(&mut self) -> &mut window::LayoutTree {
&mut self.tabs[self.active_tab].layout
}
pub fn focused_window(&self) -> window::WindowId {
self.tabs[self.active_tab].focused_window
}
pub fn set_focused_window(&mut self, id: window::WindowId) {
self.tabs[self.active_tab].focused_window = id;
}
pub fn take_layout(&mut self) -> window::LayoutTree {
std::mem::replace(self.layout_mut(), window::LayoutTree::Leaf(usize::MAX))
}
pub fn restore_layout(&mut self, layout: window::LayoutTree) {
*self.layout_mut() = layout;
}
fn focused_slot_idx(&self) -> usize {
self.windows[self.focused_window()]
.as_ref()
.expect("focused_window must point to an open window")
.slot
}
pub fn active(&self) -> &BufferSlot {
&self.slots[self.focused_slot_idx()]
}
pub fn active_mut(&mut self) -> &mut BufferSlot {
let slot_idx = self.focused_slot_idx();
&mut self.slots[slot_idx]
}
pub fn slots(&self) -> &[BufferSlot] {
&self.slots
}
pub fn slots_mut(&mut self) -> &mut [BufferSlot] {
&mut self.slots
}
pub fn active_index(&self) -> usize {
self.focused_slot_idx()
}
pub fn sync_viewport_to_editor(&mut self) {
let fw = self.focused_window();
let win = self.windows[fw].as_ref().expect("focused_window open");
let (top_row, top_col) = (win.top_row, win.top_col);
let (cursor_row, cursor_col) = (win.cursor_row, win.cursor_col);
let maybe_rect = win.last_rect;
if let Some(rect) = maybe_rect {
let vp = self.active_mut().editor.host_mut().viewport_mut();
vp.top_row = top_row;
vp.top_col = top_col;
vp.width = rect.width;
vp.height = rect.height;
}
self.active_mut().editor.jump_cursor(cursor_row, cursor_col);
}
pub fn sync_viewport_from_editor(&mut self) {
let vp = self.active().editor.host().viewport();
let (top_row, top_col) = (vp.top_row, vp.top_col);
let (cursor_row, cursor_col) = self.active().editor.cursor();
let fw = self.focused_window();
let win = self.windows[fw].as_mut().expect("focused_window open");
win.top_row = top_row;
win.top_col = top_col;
win.cursor_row = cursor_row;
win.cursor_col = cursor_col;
}
pub fn focus_below(&mut self) {
let fw = self.focused_window();
if let Some(target) = self.layout().neighbor_below(fw) {
self.sync_viewport_from_editor();
self.set_focused_window(target);
self.sync_viewport_to_editor();
}
}
pub fn focus_above(&mut self) {
let fw = self.focused_window();
if let Some(target) = self.layout().neighbor_above(fw) {
self.sync_viewport_from_editor();
self.set_focused_window(target);
self.sync_viewport_to_editor();
}
}
pub fn focus_left(&mut self) {
let fw = self.focused_window();
if let Some(target) = self.layout().neighbor_left(fw) {
self.sync_viewport_from_editor();
self.set_focused_window(target);
self.sync_viewport_to_editor();
}
}
pub fn focus_right(&mut self) {
let fw = self.focused_window();
if let Some(target) = self.layout().neighbor_right(fw) {
self.sync_viewport_from_editor();
self.set_focused_window(target);
self.sync_viewport_to_editor();
}
}
pub fn focus_next(&mut self) {
let fw = self.focused_window();
if let Some(target) = self.layout().next_leaf(fw) {
self.sync_viewport_from_editor();
self.set_focused_window(target);
self.sync_viewport_to_editor();
}
}
pub fn focus_previous(&mut self) {
let fw = self.focused_window();
if let Some(target) = self.layout().prev_leaf(fw) {
self.sync_viewport_from_editor();
self.set_focused_window(target);
self.sync_viewport_to_editor();
}
}
pub fn only_focused_window(&mut self) {
let focused = self.focused_window();
let all_leaves = self.layout().leaves();
for id in all_leaves {
if id != focused {
self.windows[id] = None;
}
}
*self.layout_mut() = window::LayoutTree::Leaf(focused);
self.status_message = Some("only".into());
}
pub fn swap_with_sibling(&mut self) {
let focused = self.focused_window();
if self.layout_mut().swap_with_sibling(focused) {
self.status_message = Some("swap".into());
}
}
pub fn move_window_to_new_tab(&mut self) -> Result<(), &'static str> {
let focused = self.focused_window();
if self.layout().leaves().len() <= 1 {
return Err("E1: only one window in this tab");
}
self.sync_viewport_from_editor();
let new_focus_in_old_tab = self
.layout_mut()
.remove_leaf(focused)
.map_err(|_| "remove_leaf failed")?;
self.tabs[self.active_tab].focused_window = new_focus_in_old_tab;
let new_tab = window::Tab {
layout: window::LayoutTree::Leaf(focused),
focused_window: focused,
};
self.tabs.push(new_tab);
self.active_tab = self.tabs.len() - 1;
self.sync_viewport_to_editor();
Ok(())
}
pub fn close_focused_window(&mut self) {
let focused = self.focused_window();
match self.layout_mut().remove_leaf(focused) {
Err(_) => {
self.status_message = Some("E444: Cannot close last window".into());
}
Ok(new_focus) => {
self.windows[focused] = None;
self.set_focused_window(new_focus);
self.sync_viewport_to_editor();
self.status_message = Some("window closed".into());
}
}
}
pub fn resize_height(&mut self, delta: i32) {
use window::SplitDir;
let fw = self.focused_window();
if let Some((ratio, Some(rect), in_a)) = self
.layout_mut()
.enclosing_split_mut(fw, SplitDir::Horizontal)
{
let parent_h = rect.height as i32;
if parent_h < 2 {
return;
}
let current_focused_height = if in_a {
(parent_h as f32 * *ratio) as i32
} else {
(parent_h as f32 * (1.0 - *ratio)) as i32
};
let new_focused = (current_focused_height + delta).clamp(1, parent_h - 1);
let new_ratio = if in_a {
new_focused as f32 / parent_h as f32
} else {
(parent_h - new_focused) as f32 / parent_h as f32
};
*ratio = new_ratio.clamp(0.01, 0.99);
}
}
pub fn resize_width(&mut self, delta: i32) {
use window::SplitDir;
let fw = self.focused_window();
if let Some((ratio, Some(rect), in_a)) = self
.layout_mut()
.enclosing_split_mut(fw, SplitDir::Vertical)
{
let parent_w = rect.width as i32;
if parent_w < 2 {
return;
}
let current_focused_width = if in_a {
(parent_w as f32 * *ratio) as i32
} else {
(parent_w as f32 * (1.0 - *ratio)) as i32
};
let new_focused = (current_focused_width + delta).clamp(1, parent_w - 1);
let new_ratio = if in_a {
new_focused as f32 / parent_w as f32
} else {
(parent_w - new_focused) as f32 / parent_w as f32
};
*ratio = new_ratio.clamp(0.01, 0.99);
}
}
pub fn equalize_layout(&mut self) {
self.layout_mut().equalize_all();
}
pub fn maximize_height(&mut self) {
use window::SplitDir;
let focused = self.focused_window();
self.layout_mut()
.for_each_ancestor(focused, &mut |dir, ratio, in_a, rect| {
if dir != SplitDir::Horizontal {
return;
}
if let Some(r) = rect {
let h = r.height as f32;
if h < 2.0 {
return;
}
let max_branch = (h - 1.0) / h;
let min_branch = 1.0 / h;
*ratio = if in_a { max_branch } else { min_branch };
}
});
}
pub fn maximize_width(&mut self) {
use window::SplitDir;
let focused = self.focused_window();
self.layout_mut()
.for_each_ancestor(focused, &mut |dir, ratio, in_a, rect| {
if dir != SplitDir::Vertical {
return;
}
if let Some(r) = rect {
let w = r.width as f32;
if w < 2.0 {
return;
}
let max_branch = (w - 1.0) / w;
let min_branch = 1.0 / w;
*ratio = if in_a { max_branch } else { min_branch };
}
});
}
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);
slot.editor.ensure_cursor_in_scrolloff();
slot.editor.set_last_search(Some(pat), true);
}
Err(e) => {
eprintln!("hjkl: bad search pattern: {e}");
}
}
}
let start_screen = if no_file {
Some(crate::start_screen::StartScreen::new())
} else {
None
};
let (initial_top_row, initial_top_col) = {
let vp = slot.editor.host().viewport();
(vp.top_row, vp.top_col)
};
let initial_window = window::Window {
slot: 0,
top_row: initial_top_row,
top_col: initial_top_col,
cursor_row: 0,
cursor_col: 0,
last_rect: None,
};
Ok(Self {
slots: vec![slot],
windows: vec![Some(initial_window)],
tabs: vec![window::Tab {
layout: window::LayoutTree::Leaf(0),
focused_window: 0,
}],
active_tab: 0,
next_window_id: 1,
pending_window_motion: false,
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_lsp: None,
pending_buffer_motion: None,
search_dir: SearchDir::Forward,
last_cursor_shape: CursorShape::Block,
syntax,
directory,
theme,
preview_highlighters: std::sync::Mutex::new(std::collections::HashMap::new()),
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,
grammar_load_error: None,
lsp: None,
lsp_state: HashMap::new(),
lsp_next_request_id: 0,
lsp_pending: HashMap::new(),
completion: None,
pending_code_actions: Vec::new(),
pending_ctrl_x: false,
mouse_enabled: crate::config::Config::default().editor.mouse,
})
}
pub fn set_mouse_capture(&mut self, on: bool) {
if self.mouse_enabled == on {
self.status_message = Some(if on { "mouse" } else { "nomouse" }.into());
return;
}
let res = if on {
crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)
} else {
crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture)
};
match res {
Ok(()) => {
self.mouse_enabled = on;
self.status_message = Some(if on { "mouse" } else { "nomouse" }.into());
}
Err(e) => {
self.status_message = Some(format!("E: failed to toggle mouse capture: {e}"));
}
}
}
pub fn with_config(mut self, config: crate::config::Config) -> Self {
self.mouse_enabled = config.editor.mouse;
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 with_lsp(mut self, lsp: hjkl_lsp::LspManager) -> Self {
self.lsp = Some(lsp);
for idx in 0..self.slots.len() {
self.lsp_attach_buffer(idx);
}
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(|_| ())
}
pub fn dismiss_completion(&mut self) {
self.completion = None;
self.pending_ctrl_x = false;
}
}