use anyhow::Result;
use hjkl_buffer::Buffer;
use hjkl_engine::{BufferEdit, Host};
use hjkl_engine::{CursorShape, Editor, Options, VimMode};
use hjkl_form::TextFieldEditor;
use hjkl_keymap::Keymap;
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::keymap_actions::AppAction;
use crate::git_worker::GitSignsWorker;
use crate::host::TuiHost;
use crate::syntax::{self, BufferId, SyntaxLayer};
mod buffer_ops;
mod event_loop;
mod ex_dispatch;
pub(crate) mod keymap;
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,
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_count: String,
pub search_dir: SearchDir,
last_cursor_shape: CursorShape,
syntax: SyntaxLayer,
git_worker: GitSignsWorker,
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 pending_prefix_at: Option<std::time::Instant>,
pub which_key_active: bool,
pub(crate) which_key_sticky: bool,
pub which_key_enabled: bool,
pub which_key_delay: std::time::Duration,
pub(crate) user_keymap_records: Vec<keymap::UserKeymapRecord>,
pub(crate) replay_depth: usize,
pub mouse_enabled: bool,
pub(crate) app_keymap: Keymap<AppAction, keymap::HjklMode>,
pub anvil_pool: hjkl_anvil::InstallPool,
pub anvil_handles: HashMap<String, hjkl_anvil::InstallHandle>,
pub anvil_log: HashMap<String, Vec<String>>,
pub anvil_registry: Option<hjkl_anvil::Registry>,
}
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)
}
fn build_app_keymap(leader: char) -> Keymap<AppAction, keymap::HjklMode> {
use keymap::HjklMode as Mode;
let mut km = Keymap::new(leader);
km.set_timeout(Duration::from_millis(500));
let bindings: &[(&str, AppAction, &str)] = &[
("<leader><leader>", AppAction::OpenFilePicker, "file picker"),
("<leader>f", AppAction::OpenFilePicker, "file picker"),
("<leader>b", AppAction::OpenBufferPicker, "buffer picker"),
("<leader>/", AppAction::OpenGrepPicker, "grep picker"),
("<leader>gs", AppAction::GitStatus, "git status"),
("<leader>gl", AppAction::GitLog, "git log"),
("<leader>gb", AppAction::GitBranch, "git branches"),
("<leader>gB", AppAction::GitFileHistory, "git file history"),
("<leader>gS", AppAction::GitStashes, "git stashes"),
("<leader>gt", AppAction::GitTags, "git tags"),
("<leader>gr", AppAction::GitRemotes, "git remotes"),
("<leader>d", AppAction::ShowDiagAtCursor, "show diagnostic"),
("<leader>ca", AppAction::LspCodeActions, "code actions"),
("<leader>rn", AppAction::LspRename, "rename symbol"),
("gt", AppAction::Tabnext, "next tab"),
("gT", AppAction::Tabprev, "prev tab"),
("gd", AppAction::LspGotoDef, "goto definition"),
("gD", AppAction::LspGotoDecl, "goto declaration"),
("gr", AppAction::LspGotoRef, "goto references"),
("gi", AppAction::LspGotoImpl, "goto implementation"),
("gy", AppAction::LspGotoTypeDef, "goto type def"),
("]b", AppAction::BufferNext, "next buffer"),
("[b", AppAction::BufferPrev, "prev buffer"),
("]d", AppAction::DiagNext, "next diagnostic"),
("[d", AppAction::DiagPrev, "prev diagnostic"),
("]D", AppAction::DiagNextError, "next error"),
("[D", AppAction::DiagPrevError, "prev error"),
("<C-w>h", AppAction::FocusLeft, "focus left"),
("<C-w>j", AppAction::FocusBelow, "focus down"),
("<C-w>k", AppAction::FocusAbove, "focus up"),
("<C-w>l", AppAction::FocusRight, "focus right"),
("<C-w>w", AppAction::FocusNext, "focus next"),
("<C-w>W", AppAction::FocusPrev, "focus prev"),
("<C-w>c", AppAction::CloseFocusedWindow, "close window"),
("<C-w>q", AppAction::QuitOrClose, "quit/close"),
("<C-w>o", AppAction::OnlyFocusedWindow, "close others"),
("<C-w>x", AppAction::SwapWithSibling, "swap with sibling"),
("<C-w>r", AppAction::SwapWithSibling, "swap with sibling"),
("<C-w>R", AppAction::SwapWithSibling, "swap with sibling"),
("<C-w>T", AppAction::MoveWindowToNewTab, "move to new tab"),
("<C-w>n", AppAction::NewSplit, "new split"),
("<C-w>+", AppAction::ResizeHeight(1), "taller"),
("<C-w>-", AppAction::ResizeHeight(-1), "shorter"),
("<C-w><gt>", AppAction::ResizeWidth(1), "wider"),
("<C-w><lt>", AppAction::ResizeWidth(-1), "narrower"),
("<C-w>=", AppAction::EqualizeLayout, "equalize"),
("<C-w>_", AppAction::MaximizeHeight, "maximize height"),
("<C-w>|", AppAction::MaximizeWidth, "maximize width"),
];
for (chord_str, action, desc) in bindings {
if let Err(e) = km.add(Mode::Normal, chord_str, action.clone(), desc) {
eprintln!("hjkl: keymap.add({chord_str:?}) failed: {e}");
}
}
km
}
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,
};
let default_leader = crate::config::Config::default().editor.leader;
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,
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_count: String::new(),
search_dir: SearchDir::Forward,
last_cursor_shape: CursorShape::Block,
syntax,
git_worker: GitSignsWorker::new(),
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,
pending_prefix_at: None,
which_key_active: false,
which_key_sticky: false,
which_key_enabled: true,
which_key_delay: std::time::Duration::from_millis(500),
user_keymap_records: Vec::new(),
replay_depth: 0,
mouse_enabled: crate::config::Config::default().editor.mouse,
app_keymap: build_app_keymap(default_leader),
anvil_pool: hjkl_anvil::InstallPool::new(),
anvil_handles: HashMap::new(),
anvil_log: HashMap::new(),
anvil_registry: hjkl_anvil::Registry::embedded().ok(),
})
}
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.which_key_enabled = config.which_key.enabled;
self.which_key_delay = std::time::Duration::from_millis(config.which_key.delay_ms);
let leader = config.editor.leader;
let timeout = Duration::from_millis(config.which_key.delay_ms);
self.app_keymap = build_app_keymap(leader);
self.app_keymap.set_timeout(timeout);
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;
}
pub fn note_prefix_set(&mut self) {
self.pending_prefix_at = Some(std::time::Instant::now());
self.which_key_active = false;
}
pub fn clear_prefix_state(&mut self) {
self.pending_prefix_at = None;
self.which_key_active = false;
}
pub fn active_which_key_prefix(&self) -> Vec<hjkl_keymap::KeyEvent> {
self.app_keymap.pending(keymap::HjklMode::Normal).to_vec()
}
pub fn dispatch_action(&mut self, action: AppAction, count: u32) {
let count = count.max(1) as usize;
match action {
AppAction::OpenFilePicker => self.open_picker(),
AppAction::OpenBufferPicker => self.open_buffer_picker(),
AppAction::OpenGrepPicker => self.open_grep_picker(None),
AppAction::GitStatus => self.open_git_status_picker(),
AppAction::GitLog => self.open_git_log_picker(),
AppAction::GitBranch => self.open_git_branch_picker(),
AppAction::GitFileHistory => self.open_git_file_history_picker(),
AppAction::GitStashes => self.open_git_stash_picker(),
AppAction::GitTags => self.open_git_tags_picker(),
AppAction::GitRemotes => self.open_git_remotes_picker(),
AppAction::ShowDiagAtCursor => self.show_diag_at_cursor(),
AppAction::LspCodeActions => self.lsp_code_actions(),
AppAction::LspRename => {
self.status_message = Some("use :Rename <newname> to rename".into());
}
AppAction::LspGotoDef => self.lsp_goto_definition(),
AppAction::LspGotoDecl => self.lsp_goto_declaration(),
AppAction::LspGotoRef => self.lsp_goto_references(),
AppAction::LspGotoImpl => self.lsp_goto_implementation(),
AppAction::LspGotoTypeDef => self.lsp_goto_type_definition(),
AppAction::Tabnext => {
for _ in 0..count {
self.dispatch_ex("tabnext");
}
}
AppAction::Tabprev => {
for _ in 0..count {
self.dispatch_ex("tabprev");
}
}
AppAction::BufferNext => self.buffer_next(),
AppAction::BufferPrev => self.buffer_prev(),
AppAction::DiagNext => self.dispatch_ex("lnext"),
AppAction::DiagPrev => self.dispatch_ex("lprev"),
AppAction::DiagNextError => self.lnext_severity(Some(DiagSeverity::Error)),
AppAction::DiagPrevError => self.lprev_severity(Some(DiagSeverity::Error)),
AppAction::FocusLeft => self.focus_left(),
AppAction::FocusBelow => self.focus_below(),
AppAction::FocusAbove => self.focus_above(),
AppAction::FocusRight => self.focus_right(),
AppAction::FocusNext => self.focus_next(),
AppAction::FocusPrev => self.focus_previous(),
AppAction::CloseFocusedWindow => self.close_focused_window(),
AppAction::OnlyFocusedWindow => self.only_focused_window(),
AppAction::SwapWithSibling => self.swap_with_sibling(),
AppAction::MoveWindowToNewTab => match self.move_window_to_new_tab() {
Ok(()) => self.status_message = Some("moved window to new tab".into()),
Err(msg) => self.status_message = Some(msg.to_string()),
},
AppAction::NewSplit => self.dispatch_ex("new"),
AppAction::ResizeHeight(delta) => self.resize_height(delta * count as i32),
AppAction::ResizeWidth(delta) => self.resize_width(delta * count as i32),
AppAction::EqualizeLayout => self.equalize_layout(),
AppAction::MaximizeHeight => self.maximize_height(),
AppAction::MaximizeWidth => self.maximize_width(),
AppAction::QuitOrClose => {
if self.layout().leaves().len() > 1 {
self.close_focused_window();
} else {
self.exit_requested = true;
}
}
AppAction::Replay { keys, recursive } => {
if recursive {
use std::collections::VecDeque;
const MAX_STEPS: usize = 1024;
const MAX_DEPTH: usize = 1024;
if self.replay_depth >= MAX_DEPTH {
self.status_message = Some("E223: recursive mapping (depth limit)".into());
return;
}
self.replay_depth += 1;
let mut queue: VecDeque<hjkl_keymap::KeyEvent> = keys.into();
let mut steps = 0usize;
while let Some(ev) = queue.pop_front() {
steps += 1;
if steps > MAX_STEPS {
self.status_message =
Some("E223: recursive mapping (1024-step limit)".into());
break;
}
let mode = current_km_mode(self);
let Some(mode) = mode else {
continue;
};
let mut sub_replay = Vec::new();
let consumed = self.dispatch_keymap_in_mode(ev, 1, &mut sub_replay, mode);
if !consumed && sub_replay.len() <= 1 {
self.replay_km_events_to_engine(&sub_replay);
}
}
self.replay_depth -= 1;
} else {
for ev in keys {
self.replay_km_events_to_engine(std::slice::from_ref(&ev));
}
}
}
}
}
pub(crate) fn replay_km_events_to_engine(&mut self, events: &[hjkl_keymap::KeyEvent]) {
for km_ev in events {
let ct_ev = crate::keymap_translate::to_crossterm(km_ev);
self.active_mut().editor.handle_key(ct_ev);
}
}
pub fn dispatch_keymap(
&mut self,
km_ev: hjkl_keymap::KeyEvent,
count: u32,
out_replay: &mut Vec<hjkl_keymap::KeyEvent>,
) -> bool {
self.dispatch_keymap_in_mode(km_ev, count, out_replay, keymap::HjklMode::Normal)
}
pub fn dispatch_keymap_in_mode(
&mut self,
km_ev: hjkl_keymap::KeyEvent,
count: u32,
out_replay: &mut Vec<hjkl_keymap::KeyEvent>,
mode: keymap::HjklMode,
) -> bool {
use hjkl_keymap::KeyResolve;
let now = std::time::Instant::now();
match self.app_keymap.feed(mode, km_ev, now) {
KeyResolve::Pending => {
self.note_prefix_set();
true
}
KeyResolve::Ambiguous => {
self.note_prefix_set();
true
}
KeyResolve::Match(binding) => {
self.clear_prefix_state();
self.dispatch_action(binding.action, count);
true
}
KeyResolve::Unbound(events) => {
self.clear_prefix_state();
out_replay.extend(events);
false
}
}
}
pub fn resolve_chord_timeout(
&mut self,
mode: keymap::HjklMode,
) -> Option<Vec<hjkl_keymap::KeyEvent>> {
use hjkl_keymap::KeyResolve;
if self.app_keymap.pending(mode).is_empty() {
return None;
}
match self.app_keymap.timeout_resolve(mode) {
KeyResolve::Match(binding) => {
self.clear_prefix_state();
self.dispatch_action(binding.action, 1);
Some(Vec::new())
}
KeyResolve::Unbound(events) if events.is_empty() => {
None
}
KeyResolve::Unbound(events) => {
self.clear_prefix_state();
Some(events)
}
_ => None,
}
}
}
pub(crate) fn current_km_mode(app: &App) -> Option<keymap::HjklMode> {
keymap::map_mode_to_km_mode(keymap::map_mode_for_vim(app.active().editor.vim_mode())?)
}