use hjkl_buffer::{BufferView, DiagOverlay, Gutter, GutterNumbers, Viewport};
use hjkl_engine::{Host, Query};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
};
use crate::app::{App, DiagSeverity, DiskState, STATUS_LINE_HEIGHT, TOP_BAR_HEIGHT, window};
fn diag_severity_style(sev: DiagSeverity) -> Style {
match sev {
DiagSeverity::Error => Style::default()
.fg(Color::Red)
.add_modifier(Modifier::UNDERLINED),
DiagSeverity::Warning => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::UNDERLINED),
DiagSeverity::Info => Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED),
DiagSeverity::Hint => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::UNDERLINED),
}
}
fn build_diag_overlays(
slot: &crate::app::BufferSlot,
_ui: &crate::theme::UiTheme,
) -> Vec<DiagOverlay> {
slot.lsp_diags
.iter()
.map(|d| DiagOverlay {
row: d.start_row,
col_start: d.start_col,
col_end: if d.end_row == d.start_row && d.end_col > d.start_col {
d.end_col
} else {
d.start_col + 1
},
style: diag_severity_style(d.severity),
})
.collect()
}
fn full_gutter_width(
lnum_width: u16,
signcolumn: hjkl_engine::types::SignColumnMode,
foldcolumn: u32,
has_visible_signs: bool,
) -> u16 {
let sign_w: u16 = match signcolumn {
hjkl_engine::types::SignColumnMode::Yes => 1,
hjkl_engine::types::SignColumnMode::No => 0,
hjkl_engine::types::SignColumnMode::Auto => {
if has_visible_signs {
1
} else {
0
}
}
};
let fold_w = foldcolumn.min(12) as u16;
lnum_width + sign_w + fold_w
}
fn parse_colorcolumn(cc: &str) -> Vec<u16> {
if cc.is_empty() {
return Vec::new();
}
let mut cols: Vec<u16> = cc
.split(',')
.filter_map(|s| s.trim().parse::<u16>().ok())
.filter(|&n| n > 0)
.collect();
cols.sort_unstable();
cols.dedup();
cols
}
fn cursor_line_bg(theme: &crate::theme::UiTheme) -> Style {
Style::default().bg(theme.cursor_line_bg)
}
fn split_rect(area: Rect, dir: window::SplitDir, ratio: f32) -> (Rect, Rect) {
match dir {
window::SplitDir::Horizontal => {
let a_h = ((area.height as f32) * ratio).round() as u16;
let a_h = a_h.clamp(1, area.height.saturating_sub(1).max(1));
let b_h = area.height.saturating_sub(a_h);
let rect_a = Rect {
x: area.x,
y: area.y,
width: area.width,
height: a_h,
};
let rect_b = Rect {
x: area.x,
y: area.y + a_h,
width: area.width,
height: b_h,
};
(rect_a, rect_b)
}
window::SplitDir::Vertical => {
let a_w = ((area.width as f32) * ratio).round() as u16;
let a_w = a_w.clamp(1, area.width.saturating_sub(1).max(1));
let b_w = area.width.saturating_sub(a_w);
let rect_a = Rect {
x: area.x,
y: area.y,
width: a_w,
height: area.height,
};
let rect_b = Rect {
x: area.x + a_w,
y: area.y,
width: b_w,
height: area.height,
};
(rect_a, rect_b)
}
}
}
fn draw_separator(
frame: &mut Frame,
sep_rect: Rect,
dir: window::SplitDir,
border_color: ratatui::style::Color,
) {
use ratatui::buffer::Cell;
let style = Style::default().fg(border_color);
let (glyph, glyph_width) = match dir {
window::SplitDir::Vertical => ("│", 1u16),
window::SplitDir::Horizontal => ("─", 1u16),
};
let buf = frame.buffer_mut();
match dir {
window::SplitDir::Vertical => {
for row in sep_rect.y..sep_rect.y + sep_rect.height {
if let Some(cell) = buf.cell_mut((sep_rect.x, row)) {
*cell = Cell::default();
cell.set_symbol(glyph);
cell.set_style(style);
}
}
}
window::SplitDir::Horizontal => {
let mut col = sep_rect.x;
while col < sep_rect.x + sep_rect.width {
if let Some(cell) = buf.cell_mut((col, sep_rect.y)) {
*cell = Cell::default();
cell.set_symbol(glyph);
cell.set_style(style);
col += glyph_width;
} else {
break;
}
}
}
}
}
fn render_layout(frame: &mut Frame, app: &mut App, area: Rect, layout: &mut window::LayoutTree) {
match layout {
window::LayoutTree::Leaf(id) => render_window(frame, app, area, *id),
window::LayoutTree::Split {
dir,
ratio,
a,
b,
last_rect,
} => {
*last_rect = Some(area);
let (rect_a, rect_b) = split_rect(area, *dir, *ratio);
let border_color = app.theme.ui.border;
let (rect_a, sep_rect, rect_b) = match *dir {
window::SplitDir::Vertical => {
if rect_a.width < 2 || rect_b.width == 0 {
(rect_a, None, rect_b)
} else {
let a_shrunk = Rect {
width: rect_a.width.saturating_sub(1),
..rect_a
};
let sep = Rect {
x: rect_a.x + rect_a.width.saturating_sub(1),
y: rect_a.y,
width: 1,
height: rect_a.height,
};
(a_shrunk, Some(sep), rect_b)
}
}
window::SplitDir::Horizontal => {
if rect_a.height < 2 || rect_b.height == 0 {
(rect_a, None, rect_b)
} else {
let a_shrunk = Rect {
height: rect_a.height.saturating_sub(1),
..rect_a
};
let sep = Rect {
x: rect_a.x,
y: rect_a.y + rect_a.height.saturating_sub(1),
width: rect_a.width,
height: 1,
};
(a_shrunk, Some(sep), rect_b)
}
}
};
render_layout(frame, app, rect_a, a);
render_layout(frame, app, rect_b, b);
if let Some(sep) = sep_rect {
draw_separator(frame, sep, *dir, border_color);
}
}
}
}
fn render_window(frame: &mut Frame, app: &mut App, area: Rect, win_id: window::WindowId) {
if let Some(win) = app.windows[win_id].as_mut() {
win.last_rect = Some(area);
}
let (slot_idx, top_row, top_col, is_focused) = {
let win = match app.windows[win_id].as_ref() {
Some(w) => w,
None => return, };
(
win.slot,
win.top_row,
win.top_col,
win_id == app.focused_window(),
)
};
let s = app.slots()[slot_idx].editor.settings();
let (nu, rnu) = (s.number, s.relativenumber);
let (scl, fdc) = (s.signcolumn, s.foldcolumn);
let (cul, cuc) = (s.cursorline, s.cursorcolumn);
let colorcolumn = s.colorcolumn.clone();
let pre_vp_top = if is_focused {
app.slots()[slot_idx].editor.host().viewport().top_row
} else {
top_row
};
let pre_vp_bot = pre_vp_top + area.height as usize;
let has_visible_signs = app.slots()[slot_idx]
.diag_signs
.iter()
.chain(app.slots()[slot_idx].diag_signs_lsp.iter())
.chain(app.slots()[slot_idx].git_signs.iter())
.any(|s| s.row >= pre_vp_top && s.row < pre_vp_bot);
let sign_w: u16 = match scl {
hjkl_engine::types::SignColumnMode::Yes => 1,
hjkl_engine::types::SignColumnMode::No => 0,
hjkl_engine::types::SignColumnMode::Auto => {
if has_visible_signs {
1
} else {
0
}
}
};
let fold_w = fdc.min(12) as u16;
let num_gw_for_text = app.slots()[slot_idx].editor.lnum_width();
let gw = sign_w + num_gw_for_text + fold_w;
let text_width = area.width.saturating_sub(gw);
if is_focused {
let tabstop = app.slots()[slot_idx].editor.settings().tabstop as u16;
let vp = app.slots_mut()[slot_idx].editor.host_mut().viewport_mut();
vp.width = text_width;
vp.height = area.height;
vp.text_width = text_width;
vp.tab_width = tabstop;
app.slots_mut()[slot_idx]
.editor
.set_viewport_height(area.height);
}
let cursor_row = app.slots()[slot_idx].editor.buffer().cursor().row;
let numbers = match (nu, rnu) {
(false, false) => GutterNumbers::None,
(true, false) => GutterNumbers::Absolute,
(false, true) => GutterNumbers::Relative { cursor_row },
(true, true) => GutterNumbers::Hybrid { cursor_row },
};
let num_gw = app.slots()[slot_idx].editor.lnum_width();
let gutter = if num_gw > 0 || sign_w > 0 {
Some(Gutter {
width: num_gw,
style: Style::default().fg(app.theme.ui.gutter),
line_offset: 0,
numbers,
sign_column_width: sign_w,
})
} else {
None
};
let viewport_owned: Viewport;
let viewport_ref: &Viewport = if is_focused {
app.slots()[slot_idx].editor.host().viewport()
} else {
viewport_owned = Viewport {
top_row,
top_col,
width: text_width,
height: area.height,
text_width,
..Viewport::default()
};
&viewport_owned
};
let in_prompt =
app.command_field.is_some() || app.search_field.is_some() || app.picker.is_some();
let vp_top = viewport_ref.top_row;
let vp_bot = vp_top + area.height as usize;
let mut visible_signs: Vec<hjkl_buffer::Sign> = app.slots()[slot_idx]
.diag_signs
.iter()
.copied()
.filter(|s| s.row >= vp_top && s.row < vp_bot)
.chain(
app.slots()[slot_idx]
.diag_signs_lsp
.iter()
.copied()
.filter(|s| s.row >= vp_top && s.row < vp_bot),
)
.chain(
app.slots()[slot_idx]
.git_signs
.iter()
.copied()
.filter(|s| s.row >= vp_top && s.row < vp_bot),
)
.collect();
visible_signs.sort_by_key(|s| s.row);
let selection = app.slots()[slot_idx].editor.buffer_selection();
let buffer_spans = app.slots()[slot_idx].editor.buffer_spans();
let search_pattern = app.slots()[slot_idx].editor.search_state().pattern.as_ref();
let search_bg = if search_pattern.is_some() {
Style::default()
.bg(app.theme.ui.search_bg)
.fg(app.theme.ui.search_fg)
} else {
Style::default()
};
let style_table = app.slots()[slot_idx].editor.style_table().to_owned();
let resolver = move |id: u32| style_table.get(id as usize).copied().unwrap_or_default();
let show_cursor = is_focused && !in_prompt;
let cursor_line_style = if show_cursor && cul {
cursor_line_bg(&app.theme.ui)
} else {
Style::default()
};
let cursor_column_style = if show_cursor && cuc {
Style::default().bg(app.theme.ui.cursor_column_bg)
} else {
Style::default()
};
let cc_cols = parse_colorcolumn(&colorcolumn);
let cc_style = Style::default().bg(app.theme.ui.colorcolumn_bg);
let diag_overlays = build_diag_overlays(&app.slots()[slot_idx], &app.theme.ui);
let view = BufferView {
buffer: app.slots()[slot_idx].editor.buffer(),
viewport: viewport_ref,
selection,
resolver: &resolver,
cursor_line_bg: cursor_line_style,
cursor_column_bg: cursor_column_style,
selection_bg: Style::default().bg(Color::Blue),
cursor_style: Style::default(),
gutter,
search_bg,
signs: &visible_signs,
conceals: &[],
spans: buffer_spans,
search_pattern,
non_text_style: Style::default().fg(app.theme.ui.non_text),
diag_overlays: &diag_overlays,
colorcolumn_cols: &cc_cols,
colorcolumn_style: cc_style,
};
frame.render_widget(view, area);
if is_focused && let Some((flash_top, flash_bot)) = app.indent_flash_active() {
let flash_bg = app.theme.ui.indent_flash_bg;
let flash_style = Style::default().bg(flash_bg);
let buf = frame.buffer_mut();
let screen_top = area.y;
let screen_height = area.height;
let text_x = area.x + sign_w + num_gw;
let text_right = area.x + area.width;
let vis_start = flash_top.max(vp_top);
let vis_end = flash_bot.min(vp_top + screen_height as usize - 1);
for buf_row in vis_start..=vis_end {
let screen_row = screen_top + (buf_row - vp_top) as u16;
if screen_row >= screen_top + screen_height {
break;
}
for col in text_x..text_right {
let cell = buf.cell_mut((col, screen_row));
if let Some(c) = cell {
c.set_style(c.style().patch(flash_style));
}
}
}
}
if show_cursor
&& let Some((cx, cy)) = app.slots_mut()[slot_idx]
.editor
.cursor_screen_pos_in_rect(area, sign_w + fold_w)
{
frame.set_cursor_position((cx, cy));
}
}
fn completion_popup(frame: &mut Frame, app: &App, buf_area: Rect) {
let popup = match app.completion.as_ref() {
Some(p) => p,
None => return,
};
if popup.is_empty() {
return;
}
let slot_idx = {
let fw = app.focused_window();
app.windows[fw].as_ref().map(|w| w.slot).unwrap_or(0)
};
let s = app.slots()[slot_idx].editor.settings();
let vp = app.slots()[slot_idx].editor.host().viewport();
let vp_top = vp.top_row;
let vp_bot = vp_top + 100; let has_visible_signs = app.slots()[slot_idx]
.diag_signs
.iter()
.chain(app.slots()[slot_idx].diag_signs_lsp.iter())
.chain(app.slots()[slot_idx].git_signs.iter())
.any(|sg| sg.row >= vp_top && sg.row < vp_bot);
let gw = full_gutter_width(
app.slots()[slot_idx].editor.lnum_width(),
s.signcolumn,
s.foldcolumn,
has_visible_signs,
);
let screen_row = popup.anchor_row.saturating_sub(vp_top) as u16 + 1;
let screen_col = popup.anchor_col as u16 + gw;
const MIN_WIDTH: u16 = 20;
const MAX_HEIGHT: u16 = 10;
let visible_count = popup.visible.len().min(MAX_HEIGHT as usize) as u16;
if visible_count == 0 {
return;
}
let content_width = popup
.visible
.iter()
.filter_map(|&idx| popup.all_items.get(idx))
.map(|item| {
let detail_len = item.detail.as_deref().map(|d| d.len() + 2).unwrap_or(0);
1 + 1 + item.label.len() + 2 + detail_len
})
.max()
.unwrap_or(MIN_WIDTH as usize) as u16;
let popup_w = content_width.max(MIN_WIDTH).min(buf_area.width);
let abs_row = buf_area.y + screen_row;
let abs_col = buf_area.x + screen_col;
let popup_h = visible_count + 2; let popup_y = if abs_row + popup_h > buf_area.y + buf_area.height {
abs_row.saturating_sub(popup_h + 1).max(buf_area.y)
} else {
abs_row
};
let popup_x = abs_col.min(buf_area.x + buf_area.width.saturating_sub(popup_w));
let area = Rect {
x: popup_x,
y: popup_y,
width: popup_w,
height: popup_h,
};
frame.render_widget(Clear, area);
let ui = &app.theme.ui;
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ui.border_active));
let inner = block.inner(area);
frame.render_widget(block, area);
let selected_style = Style::default()
.bg(ui.picker_selection_bg)
.add_modifier(Modifier::BOLD);
let normal_style = Style::default().fg(ui.text);
let detail_style = Style::default().fg(ui.text_dim);
let items: Vec<ListItem> = popup
.visible
.iter()
.enumerate()
.filter_map(|(vis_idx, &item_idx)| {
let item = popup.all_items.get(item_idx)?;
let icon = item.kind.icon();
let label = &item.label;
let mut spans = vec![
Span::styled(
format!("{icon} "),
if vis_idx == popup.selected {
selected_style
} else {
normal_style
},
),
Span::styled(
label.clone(),
if vis_idx == popup.selected {
selected_style
} else {
normal_style
},
),
];
if let Some(ref detail) = item.detail {
let avail = inner.width.saturating_sub(2 + label.len() as u16 + 2) as usize;
let truncated: String = detail.chars().take(avail).collect();
if !truncated.is_empty() {
spans.push(Span::styled(
format!(" {truncated}"),
if vis_idx == popup.selected {
selected_style
} else {
detail_style
},
));
}
}
Some(ListItem::new(Line::from(spans)))
})
.collect();
let mut state = ListState::default();
state.select(Some(popup.selected.min(items.len().saturating_sub(1))));
let list = List::new(items).highlight_style(selected_style);
frame.render_stateful_widget(list, inner, &mut state);
}
pub fn frame(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let show_top_bar = app.tabs.len() > 1 || app.slots().len() > 1;
let (buf_area, status_area, top_bar_area) = {
let mut constraints = Vec::new();
if show_top_bar {
constraints.push(Constraint::Length(TOP_BAR_HEIGHT));
}
constraints.push(Constraint::Min(1));
constraints.push(Constraint::Length(STATUS_LINE_HEIGHT));
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let mut idx = 0usize;
let tb_area = if show_top_bar {
let a = Some(chunks[idx]);
idx += 1;
a
} else {
None
};
let buf = chunks[idx];
idx += 1;
let stat = chunks[idx];
(buf, stat, tb_area)
};
if let Some(ref screen) = app.start_screen {
if let Some(tb) = top_bar_area {
top_bar(frame, app, tb);
}
crate::start_screen::render(frame, buf_area, screen, &app.theme);
status_line(frame, app, status_area);
return;
}
app.recompute_and_install();
if let Some(tb) = top_bar_area {
top_bar(frame, app, tb);
}
let mut layout = app.take_layout();
render_layout(frame, app, buf_area, &mut layout);
app.restore_layout(layout);
if app.command_completion.is_some() && buf_area.height >= 2 {
let wm_row = buf_area.y + buf_area.height - 1;
let wm_area = Rect {
x: buf_area.x,
y: wm_row,
width: buf_area.width,
height: 1,
};
wildmenu(frame, app, wm_area);
}
status_line(frame, app, status_area);
if app.picker.is_some() {
picker_overlay(frame, app, buf_area);
}
if app.completion.is_some() {
completion_popup(frame, app, buf_area);
}
if app.which_key_active {
which_key_popup(frame, app, buf_area);
}
if app.info_popup.is_some() {
info_popup_overlay(frame, app, buf_area);
}
if let Some(ref menu) = app.context_menu {
menu.render(frame, area);
}
if let Some(ref popup) = app.hover_popup {
popup.render(frame, frame.area(), &app.theme);
}
}
fn top_bar(frame: &mut Frame, app: &App, area: Rect) {
let ui = &app.theme.ui;
let active_style = Style::default()
.fg(ui.on_accent)
.bg(ui.mode_normal_bg)
.add_modifier(Modifier::BOLD);
let inactive_style = Style::default().fg(ui.text_dim);
let sep_style = Style::default().fg(ui.border);
let show_tabs = app.tabs.len() > 1;
let show_buffers = app.slots().len() > 1;
let total_width = area.width as usize;
struct TabEntry {
label: String,
is_active: bool,
has_sep: bool, }
let mut tab_entries: Vec<TabEntry> = Vec::new();
let mut tabs_total_len = 0usize;
if show_tabs {
for (i, tab) in app.tabs.iter().enumerate() {
let slot_idx = app.windows[tab.focused_window]
.as_ref()
.map(|w| w.slot)
.unwrap_or(0);
let slot = &app.slots()[slot_idx];
let base_name = slot
.filename
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("[No Name]");
let tab_dirty = tab.layout.leaves().iter().any(|&wid| {
app.windows[wid]
.as_ref()
.map(|w| app.slots()[w.slot].dirty)
.unwrap_or(false)
});
let label = if tab_dirty {
format!(" {}: {}+ ", i + 1, base_name)
} else {
format!(" {}: {} ", i + 1, base_name)
};
let has_sep = i > 0;
let entry_width = if has_sep { 1 } else { 0 } + label.len();
tabs_total_len += entry_width;
tab_entries.push(TabEntry {
label,
is_active: i == app.active_tab,
has_sep,
});
}
}
let buf_budget = if show_buffers {
total_width.saturating_sub(tabs_total_len)
} else {
0
};
let mut buf_spans: Vec<Span<'static>> = Vec::new();
let mut buf_used = 0usize;
if show_buffers && buf_budget > 0 {
for (i, slot) in app.slots().iter().enumerate() {
let base_name = slot
.filename
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("[No Name]");
let label = if slot.dirty {
format!(" {}+ ", base_name)
} else {
format!(" {} ", base_name)
};
let sep_len = if i == 0 { 0 } else { 1 };
let entry_width = sep_len + label.len();
if buf_used + entry_width > buf_budget {
if buf_used < buf_budget {
buf_spans.push(Span::styled("…".to_string(), sep_style));
}
break;
}
if i > 0 {
buf_spans.push(Span::styled("│".to_string(), sep_style));
}
let style = if i == app.active_index() {
active_style
} else {
inactive_style
};
buf_spans.push(Span::styled(label, style));
buf_used += entry_width;
}
}
let mut all_spans: Vec<Span<'static>> = buf_spans;
if show_tabs {
let used_left = buf_used;
let pad_width = total_width.saturating_sub(used_left + tabs_total_len);
if pad_width > 0 {
all_spans.push(Span::raw(" ".repeat(pad_width)));
}
for entry in &tab_entries {
if entry.has_sep {
all_spans.push(Span::styled(" ".to_string(), sep_style));
}
let style = if entry.is_active {
active_style
} else {
inactive_style
};
all_spans.push(Span::styled(entry.label.clone(), style));
}
}
let paragraph = Paragraph::new(Line::from(all_spans));
frame.render_widget(paragraph, area);
}
fn wildmenu(frame: &mut Frame, app: &App, area: Rect) {
let comp = match &app.command_completion {
Some(c) => c,
None => return,
};
let ui = &app.theme.ui;
let normal_style = Style::default().bg(ui.panel_bg).fg(ui.text);
let selected_style = Style::default().bg(ui.picker_selection_bg).fg(ui.text);
let width = area.width as usize;
let sep = " ";
let sep_len = sep.len();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
let n = comp.candidates.len();
for (i, cand) in comp.candidates.iter().enumerate() {
let is_selected = comp.selected == Some(i);
let entry = cand.clone();
let entry_len = entry.chars().count();
let remaining = n - i;
let suffix = if remaining > 1 {
format!(" +{} more", remaining - 1)
} else {
String::new()
};
if used + entry_len > width {
if !suffix.is_empty() && used + suffix.len() <= width {
spans.push(Span::styled(suffix, normal_style));
}
break;
}
if i > 0 {
if used + sep_len + entry_len > width {
if used + suffix.len() <= width {
spans.push(Span::styled(suffix, normal_style));
}
break;
}
spans.push(Span::styled(sep.to_string(), normal_style));
used += sep_len;
}
let style = if is_selected {
selected_style
} else {
normal_style
};
spans.push(Span::styled(entry, style));
used += entry_len;
}
if used < width {
let pad = " ".repeat(width - used);
spans.push(Span::styled(pad, normal_style));
}
frame.render_widget(Clear, area);
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn status_line(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let (status, cursor_col) = build_status_line(app, area.width);
let paragraph = Paragraph::new(status);
frame.render_widget(paragraph, area);
if let Some(col) = cursor_col {
frame.set_cursor_position((area.x + col, area.y));
}
}
fn prompt_line(
content: &str,
mode: hjkl_form::VimMode,
theme: &crate::theme::UiTheme,
width: u16,
) -> Line<'static> {
let (bg, tag, tag_fg) = match mode {
hjkl_form::VimMode::Insert => (theme.form_insert_bg, " [I]", theme.form_tag_insert_fg),
_ => (theme.form_normal_bg, " [N]", theme.form_tag_normal_fg),
};
let body_width = (width as usize).saturating_sub(tag.len());
let visible: String = content.chars().take(body_width).collect();
let body = format!("{visible:<body_width$}");
Line::from(vec![
Span::styled(body, Style::default().bg(bg).fg(theme.text)),
Span::styled(tag, Style::default().bg(bg).fg(tag_fg)),
])
}
pub(crate) fn search_count(app: &App) -> Option<(usize, usize)> {
const MATCH_CAP: usize = 10_000;
let st = app.active().editor.search_state();
let pat = st.pattern.as_ref()?;
let buf = app.active().editor.buffer();
let (cursor_row, cursor_col) = app.active().editor.cursor();
let cursor_byte = buf
.lines()
.get(cursor_row)
.map(|line| {
line.char_indices()
.nth(cursor_col)
.map(|(b, _)| b)
.unwrap_or(line.len())
})
.unwrap_or(0);
let mut total = 0usize;
let mut current_idx = 0usize;
'outer: for (row_idx, line) in buf.lines().iter().enumerate() {
for m in pat.find_iter(line) {
total += 1;
if (row_idx, m.start()) <= (cursor_row, cursor_byte) {
current_idx = total;
}
if total >= MATCH_CAP {
break 'outer;
}
}
}
if total == 0 {
None
} else {
Some((current_idx, total))
}
}
fn build_status_line(app: &App, width: u16) -> (Line<'static>, Option<u16>) {
if let Some(ref field) = app.command_field {
let text = field.text();
let display: String = text.lines().next().unwrap_or("").to_string();
let content = format!(":{display}");
let (_, ccol) = field.cursor();
let cursor_col = 1u16 + ccol as u16;
return (
prompt_line(&content, field.vim_mode(), &app.theme.ui, width),
Some(cursor_col),
);
}
if let Some(ref field) = app.search_field {
let prefix = match app.search_dir {
crate::app::SearchDir::Forward => '/',
crate::app::SearchDir::Backward => '?',
};
let text = field.text();
let display: String = text.lines().next().unwrap_or("").to_string();
let content = format!("{prefix}{display}");
let (_, ccol) = field.cursor();
let cursor_col = 1u16 + ccol as u16;
return (
prompt_line(&content, field.vim_mode(), &app.theme.ui, width),
Some(cursor_col),
);
}
if app.perf_overlay {
let p = &app.last_perf;
let content = format!(
" perf total={}µs src={} parse={} hl={} byrow={} diag={} install={} sig={} git={} | runs={} hits={} thr={} ",
app.last_recompute_us,
p.source_build_us,
p.parse_us,
p.highlight_us,
p.by_row_us,
p.diag_us,
app.last_install_us,
app.last_signature_us,
app.last_git_us,
app.recompute_runs,
app.recompute_hits,
app.recompute_throttled,
);
let padded = format!("{content:<width$}", width = width as usize);
return (
Line::from(vec![Span::styled(
padded,
Style::default()
.bg(app.theme.ui.surface_bg)
.fg(app.theme.ui.text),
)]),
None,
);
}
if let Some(ref msg) = app.status_message {
let content = format!(" {msg}");
let padded = format!("{content:<width$}", width = width as usize);
return (
Line::from(vec![Span::styled(
padded,
Style::default()
.bg(app.theme.ui.surface_bg)
.fg(app.theme.ui.text),
)]),
None,
);
}
if let Some(reg) = app.active().editor.recording_register() {
let content = format!(" recording @{reg}");
let padded = format!("{content:<width$}", width = width as usize);
return (
Line::from(vec![Span::styled(
padded,
Style::default()
.bg(app.theme.ui.surface_bg)
.fg(app.theme.ui.text)
.add_modifier(Modifier::BOLD),
)]),
None,
);
}
if let Some(err) = &app.grammar_load_error
&& !err.is_expired()
{
let content = format!(" grammar load failed: {} — {}", err.name, err.message);
let truncated = if content.len() > width as usize {
let max = (width as usize).saturating_sub(1);
format!("{}…", &content[..max.min(content.len())])
} else {
content
};
let padded = format!("{truncated:<width$}", width = width as usize);
return (
Line::from(vec![Span::styled(
padded,
Style::default()
.bg(app.theme.ui.surface_bg)
.fg(app.theme.ui.status_dirty_marker)
.add_modifier(Modifier::BOLD),
)]),
None,
);
}
let ui = &app.theme.ui;
let mode = app.mode_label();
let mode_color = match mode {
"INSERT" => ui.mode_insert_bg,
"VISUAL" | "VISUAL LINE" | "VISUAL BLOCK" => ui.mode_visual_bg,
_ => ui.mode_normal_bg,
};
let mode_style = Style::default()
.bg(mode_color)
.fg(ui.on_accent)
.add_modifier(Modifier::BOLD);
let mid_style = Style::default().bg(ui.surface_bg).fg(ui.text);
let fill_style = Style::default().bg(ui.panel_bg).fg(ui.text);
let dirty_style = Style::default()
.bg(ui.surface_bg)
.fg(ui.status_dirty_marker);
let ro_tag = if app.active().editor.is_readonly() {
" [RO]"
} else {
""
};
let new_tag = if app.active().is_new_file {
" [New File]"
} else {
""
};
let disk_tag = match app.active().disk_state {
DiskState::DeletedOnDisk => " [deleted]",
DiskState::ChangedOnDisk => " [changed on disk]",
DiskState::Synced => "",
};
let untracked_tag = if app.active().is_untracked && !app.active().is_new_file {
" [Untracked]"
} else {
""
};
let raw_filename: String = app
.active()
.filename
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("[No Name]")
.to_owned();
let (row, col) = app.active().editor.cursor();
let line_count = app.active().editor.buffer().line_count() as usize;
let pct = ((row + 1) * 100).checked_div(line_count).unwrap_or(0);
let mode_block = format!(" {mode} ");
let pos_block = format!(" {}:{} ", row + 1, col + 1);
let pct_block = format!(" {pct}% ");
let dirty_block = if app.active().dirty { " ● " } else { "" };
let rec_block = match app.active().editor.recording_register() {
Some(reg) => format!(" REC @{reg} "),
None => String::new(),
};
let pending_block: String = {
let pc = app.active().editor.pending_count();
let po = app.active().editor.pending_op();
match (pc, po) {
(Some(n), Some(op)) => format!(" {n}{op} "),
(Some(n), None) => format!(" {n} "),
(None, Some(op)) => format!(" {op} "),
(None, None) => String::new(),
}
};
let search_count_block: String = search_count(app)
.map(|(idx, total)| format!(" [{idx}/{total}] "))
.unwrap_or_default();
let diag_count_block: String = {
let diags = &app.active().lsp_diags;
if diags.is_empty() {
String::new()
} else {
let e = diags
.iter()
.filter(|d| d.severity == DiagSeverity::Error)
.count();
let w2 = diags
.iter()
.filter(|d| d.severity == DiagSeverity::Warning)
.count();
let i = diags
.iter()
.filter(|d| d.severity == DiagSeverity::Info)
.count();
let h = diags
.iter()
.filter(|d| d.severity == DiagSeverity::Hint)
.count();
let mut parts = Vec::new();
if e > 0 {
parts.push(format!("E:{e}"));
}
if w2 > 0 {
parts.push(format!("W:{w2}"));
}
if i > 0 {
parts.push(format!("I:{i}"));
}
if h > 0 {
parts.push(format!("H:{h}"));
}
if parts.is_empty() {
String::new()
} else {
format!(" {} ", parts.join(" "))
}
}
};
let suffix = format!("{ro_tag}{new_tag}{disk_tag}{untracked_tag}");
let loading_block: String = if !app.lsp_pending.is_empty() {
let label = app
.lsp_pending
.values()
.next()
.map(|p| match p {
crate::app::LspPendingRequest::GotoDefinition { .. } => "definition",
crate::app::LspPendingRequest::GotoDeclaration { .. } => "declaration",
crate::app::LspPendingRequest::GotoTypeDefinition { .. } => "type definition",
crate::app::LspPendingRequest::GotoImplementation { .. } => "implementation",
crate::app::LspPendingRequest::GotoReferences { .. } => "references",
crate::app::LspPendingRequest::Hover { .. } => "hover",
crate::app::LspPendingRequest::Completion { .. } => "completion",
crate::app::LspPendingRequest::CodeAction { .. } => "code action",
crate::app::LspPendingRequest::Rename { .. } => "rename",
_ => "request",
})
.unwrap_or("request");
format!(" {} LSP:{label} ", hjkl_editor_tui::spinner::frame())
} else {
let names = app.directory.in_flight_names();
match names.len() {
0 => String::new(),
1 => format!(
" {} grammar:{} ",
hjkl_editor_tui::spinner::frame(),
names[0]
),
n => format!(
" {} grammar:{} +{} ",
hjkl_editor_tui::spinner::frame(),
names[0],
n - 1
),
}
};
let w = width as usize;
let reserved = mode_block.len()
+ rec_block.len()
+ pending_block.len()
+ 2
+ suffix.len()
+ dirty_block.len()
+ search_count_block.len()
+ diag_count_block.len()
+ loading_block.len()
+ pos_block.len()
+ pct_block.len();
let avail_for_name = w.saturating_sub(reserved);
let filename: String = if raw_filename.len() <= avail_for_name {
raw_filename.clone()
} else if avail_for_name <= 1 {
String::new()
} else {
let keep = avail_for_name.saturating_sub(1);
let start = raw_filename.len().saturating_sub(keep);
format!("\u{2026}{}", &raw_filename[start..])
};
let mid_block = format!(" {filename}{suffix} ");
let used = mode_block.len()
+ rec_block.len()
+ pending_block.len()
+ mid_block.len()
+ dirty_block.len()
+ search_count_block.len()
+ diag_count_block.len()
+ loading_block.len()
+ pos_block.len()
+ pct_block.len();
let spacer: String = " ".repeat(w.saturating_sub(used));
let rec_style = Style::default()
.bg(ui.recording_bg)
.fg(ui.recording_fg)
.add_modifier(Modifier::BOLD);
let pending_style = Style::default()
.bg(ui.surface_bg)
.fg(ui.text)
.add_modifier(Modifier::ITALIC);
let mut spans: Vec<Span<'static>> = Vec::with_capacity(9);
spans.push(Span::styled(mode_block, mode_style));
if !rec_block.is_empty() {
spans.push(Span::styled(rec_block, rec_style));
}
if !pending_block.is_empty() {
spans.push(Span::styled(pending_block, pending_style));
}
spans.push(Span::styled(mid_block, mid_style));
if !dirty_block.is_empty() {
spans.push(Span::styled(dirty_block.to_string(), dirty_style));
}
if !search_count_block.is_empty() {
spans.push(Span::styled(search_count_block, mid_style));
}
if !diag_count_block.is_empty() {
let diags = &app.active().lsp_diags;
let diag_style = if diags.iter().any(|d| d.severity == DiagSeverity::Error) {
Style::default()
.bg(ui.surface_bg)
.fg(Color::Red)
.add_modifier(Modifier::BOLD)
} else if diags.iter().any(|d| d.severity == DiagSeverity::Warning) {
Style::default().bg(ui.surface_bg).fg(Color::Yellow)
} else if diags.iter().any(|d| d.severity == DiagSeverity::Info) {
Style::default().bg(ui.surface_bg).fg(Color::Blue)
} else {
Style::default().bg(ui.surface_bg).fg(Color::Cyan)
};
spans.push(Span::styled(diag_count_block, diag_style));
}
if !loading_block.is_empty() {
let loading_style = Style::default()
.bg(ui.surface_bg)
.fg(ui.text)
.add_modifier(Modifier::ITALIC);
spans.push(Span::styled(loading_block, loading_style));
}
spans.push(Span::styled(spacer, fill_style));
spans.push(Span::styled(pos_block, mid_style));
spans.push(Span::styled(pct_block, mode_style));
(Line::from(spans), None)
}
#[cfg(test)]
pub fn format_status_line(
mode: &str,
filename: &str,
dirty: bool,
row: usize,
col: usize,
total_lines: usize,
width: u16,
) -> String {
format_status_line_full(
mode,
filename,
dirty,
false,
false,
row,
col,
total_lines,
width,
)
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub fn format_status_line_full(
mode: &str,
filename: &str,
dirty: bool,
readonly: bool,
is_new_file: bool,
row: usize,
col: usize,
total_lines: usize,
width: u16,
) -> String {
let dirty_marker = if dirty { "*" } else { " " };
let ro_tag = if readonly { " [RO]" } else { "" };
let new_tag = if is_new_file { " [New File]" } else { "" };
let pct = ((row + 1) * 100).checked_div(total_lines).unwrap_or(0);
let pos = format!("{}:{}", row + 1, col + 1);
let pct_str = format!("{pct}%");
let right = format!("{pos} {pct_str} ");
let left_prefix = format!(" {mode} {dirty_marker} ");
let suffix = format!("{ro_tag}{new_tag}");
let w = width as usize;
let reserved = left_prefix.len() + suffix.len() + right.len();
let avail_for_name = w.saturating_sub(reserved);
let truncated: String = if filename.len() <= avail_for_name {
filename.to_string()
} else if avail_for_name <= 1 {
String::new()
} else {
let keep = avail_for_name.saturating_sub(1);
let start = filename.len().saturating_sub(keep);
format!("\u{2026}{}", &filename[start..])
};
let left = format!("{left_prefix}{truncated}{suffix}");
let used = left.len() + right.len();
let pad_count = w.saturating_sub(used);
let spacer = " ".repeat(pad_count);
format!("{left}{spacer}{right}")
}
#[cfg(test)]
pub fn format_write_message(path: &str, lines: usize, bytes: usize) -> String {
format!("\"{}\" {}L, {}B written", path, lines, bytes)
}
fn picker_overlay(frame: &mut Frame, app: &mut App, buf_area: Rect) {
let area = centered_rect(80, 70, buf_area);
frame.render_widget(Clear, area);
let p = match app.picker.as_mut() {
Some(p) => p,
None => return,
};
p.tick(std::time::Instant::now());
p.refresh();
p.refresh_preview();
const PREVIEW_MIN_WIDTH: u16 = 80;
let with_preview = p.has_preview() && area.width >= PREVIEW_MIN_WIDTH;
let (left_area, preview_area) = if with_preview {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
(cols[0], Some(cols[1]))
} else {
(area, None)
};
render_picker_input_and_list(frame, p, &app.theme.ui, left_area);
if let Some(right) = preview_area {
let ui = &app.theme.ui;
let theme = hjkl_picker_tui::PreviewTheme {
border: Style::default().fg(ui.border),
gutter: Style::default().fg(ui.gutter),
non_text: Style::default().fg(ui.non_text),
cursor_line: cursor_line_bg(ui),
};
let picker = app.picker.as_ref().expect("picker still set");
hjkl_picker_tui::preview_pane(frame, picker, app, &theme, right);
}
}
fn engine_style_to_ratatui(s: hjkl_engine::types::Style) -> Style {
use hjkl_engine::types::Attrs;
let mut out = Style::default();
if let Some(fg) = s.fg {
out = out.fg(Color::Rgb(fg.0, fg.1, fg.2));
}
if let Some(bg) = s.bg {
out = out.bg(Color::Rgb(bg.0, bg.1, bg.2));
}
let mut m = Modifier::empty();
if s.attrs.contains(Attrs::BOLD) {
m |= Modifier::BOLD;
}
if s.attrs.contains(Attrs::ITALIC) {
m |= Modifier::ITALIC;
}
if s.attrs.contains(Attrs::UNDERLINE) {
m |= Modifier::UNDERLINED;
}
if s.attrs.contains(Attrs::REVERSE) {
m |= Modifier::REVERSED;
}
if s.attrs.contains(Attrs::DIM) {
m |= Modifier::DIM;
}
if s.attrs.contains(Attrs::STRIKE) {
m |= Modifier::CROSSED_OUT;
}
if !m.is_empty() {
out = out.add_modifier(m);
}
out
}
fn render_picker_input_and_list(
frame: &mut Frame,
picker: &mut crate::picker::Picker,
theme: &crate::theme::UiTheme,
left_area: Rect,
) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(left_area);
let input_area = layout[0];
let list_area = layout[1];
let total = picker.total();
let matched = picker.matched();
let scan_tag = if picker.scan_done() {
"".to_string()
} else {
format!(" {} scanning", hjkl_editor_tui::spinner::frame())
};
let kind = picker.title();
let title = format!(" picker — {kind} — {matched}/{total}{scan_tag} ");
let query_text = picker.query.text();
let display: String = query_text.lines().next().unwrap_or("").to_string();
let input_block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(theme.border_active));
let input_inner = input_block.inner(input_area);
frame.render_widget(input_block, input_area);
let input_para = Paragraph::new(format!("/ {display}"));
frame.render_widget(input_para, input_inner);
let (_, ccol) = picker.query.cursor();
let cx = input_inner.x + 2 + ccol as u16;
let cy = input_inner.y;
if cx < input_inner.x + input_inner.width && cy < input_inner.y + input_inner.height {
frame.set_cursor_position((cx, cy));
}
let entries = picker.visible_entries();
let row_styles = picker.visible_entry_styles();
let match_style = Style::default()
.fg(theme.search_bg)
.add_modifier(Modifier::BOLD);
let items: Vec<ListItem> = entries
.iter()
.enumerate()
.map(|(row_idx, (label, matches))| {
let styles = row_styles.get(row_idx).map(Vec::as_slice).unwrap_or(&[]);
if matches.is_empty() && styles.is_empty() {
return ListItem::new(label.clone());
}
let spans: Vec<Span> = label
.chars()
.enumerate()
.map(|(ci, ch)| {
let s = ch.to_string();
let base_engine = styles
.iter()
.find(|(r, _)| r.contains(&ci))
.map(|(_, st)| *st)
.unwrap_or_default();
let base = engine_style_to_ratatui(base_engine);
if matches.contains(&ci) {
Span::styled(s, base.patch(match_style))
} else if base != Style::default() {
Span::styled(s, base)
} else {
Span::raw(s)
}
})
.collect();
ListItem::new(Line::from(spans))
})
.collect();
let label_count = entries.len();
let list_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border));
let list = List::new(items).block(list_block).highlight_style(
Style::default()
.bg(theme.picker_selection_bg)
.add_modifier(Modifier::BOLD),
);
let mut state = ListState::default();
if label_count > 0 {
state.select(Some(picker.selected.min(label_count.saturating_sub(1))));
}
frame.render_stateful_widget(list, list_area, &mut state);
}
fn info_popup_overlay(frame: &mut Frame, app: &App, buf_area: Rect) {
let text = match app.info_popup.as_ref() {
Some(t) => t,
None => return,
};
let area = centered_rect(80, 60, buf_area);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(app.theme.ui.border_active))
.title(" info ");
let inner = block.inner(area);
frame.render_widget(block, area);
let para = Paragraph::new(text.clone());
frame.render_widget(para, inner);
}
fn which_key_popup(frame: &mut Frame, app: &App, buf_area: Rect) {
if !app.which_key_active {
return;
}
let pending = app.active_which_key_prefix();
if pending.is_empty() && !app.which_key_sticky {
return;
}
let leader = app.config.editor.leader;
let entries = crate::which_key::entries_for(
&app.app_keymap,
crate::app::keymap::HjklMode::Normal,
&pending,
leader,
);
if entries.is_empty() {
return;
}
let ui = &app.theme.ui;
let entry_width = entries
.iter()
.map(|e| e.key.len() + 1 + e.desc.len()) .max()
.unwrap_or(8) as u16;
let col_width = entry_width + 2;
let available_width = buf_area.width.saturating_sub(2); let cols = (available_width / col_width).max(1) as usize;
let rows_needed = entries.len().div_ceil(cols);
const MAX_ROWS: usize = 12;
let content_rows = rows_needed.min(MAX_ROWS) as u16;
let popup_h = content_rows + 3;
let popup_y = buf_area.y + buf_area.height.saturating_sub(popup_h);
let popup_w = buf_area.width;
let area = Rect {
x: buf_area.x,
y: popup_y,
width: popup_w,
height: popup_h,
};
frame.render_widget(Clear, area);
let header_label = if pending.is_empty() {
"root".to_string()
} else {
hjkl_keymap::Chord(pending.clone()).to_notation(leader)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ui.border_active))
.title(format!(" {header_label} "));
let inner = block.inner(area);
frame.render_widget(block, area);
let key_style = Style::default()
.fg(ui.border_active)
.add_modifier(Modifier::BOLD);
let desc_style = Style::default().fg(ui.text_dim);
let mut lines: Vec<Line> = Vec::new();
let visible_entries = entries.iter().take(MAX_ROWS * cols);
let collected: Vec<&crate::which_key::Entry> = visible_entries.collect();
for row in 0..content_rows as usize {
let mut spans: Vec<Span> = Vec::new();
for col in 0..cols {
let idx = row * cols + col;
if let Some(entry) = collected.get(idx) {
let key_str = &entry.key;
let desc_str = &entry.desc;
let entry_str = format!("{key_str} {desc_str}");
let padded_len = col_width as usize;
let padding = " ".repeat(padded_len.saturating_sub(entry_str.len()));
spans.push(Span::styled(key_str.clone(), key_style));
spans.push(Span::styled(format!(" {desc_str}{padding}"), desc_style));
}
}
lines.push(Line::from(spans));
}
let para = Paragraph::new(lines);
frame.render_widget(para, inner);
}
fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let width = area.width.saturating_mul(pct_x) / 100;
let height = area.height.saturating_mul(pct_y) / 100;
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect {
x,
y,
width,
height,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_line_normal_mode_no_name() {
let s = format_status_line("NORMAL", "[No Name]", false, 0, 0, 1, 60);
assert!(s.contains("NORMAL"));
assert!(s.contains("[No Name]"));
assert!(s.contains("1:1"));
assert!(s.contains("100%"));
}
#[test]
fn status_line_dirty_marker() {
let clean = format_status_line("NORMAL", "foo.txt", false, 0, 0, 1, 60);
let dirty = format_status_line("NORMAL", "foo.txt", true, 0, 0, 1, 60);
assert!(clean.contains(" [No Name]") || clean.contains(" foo.txt"));
let dirty_idx = dirty.find('*');
assert!(dirty_idx.is_some(), "dirty status should contain '*'");
let clean_contains_star = clean.contains('*');
assert!(!clean_contains_star, "clean status should not contain '*'");
}
#[test]
fn status_line_percentage() {
let s = format_status_line("NORMAL", "f.txt", false, 4, 0, 10, 60);
assert!(s.contains("50%"));
}
#[test]
fn status_line_fits_width() {
let width: u16 = 40;
let s = format_status_line("INSERT", "myfile.rs", true, 0, 0, 100, width);
assert_eq!(s.len(), width as usize);
}
#[test]
fn write_message_format() {
let msg = format_write_message("/tmp/foo.txt", 10, 128);
assert_eq!(msg, "\"/tmp/foo.txt\" 10L, 128B written");
}
#[test]
fn status_line_readonly_tag() {
let s = format_status_line_full("NORMAL", "foo.txt", false, true, false, 0, 0, 1, 80);
assert!(s.contains("[RO]"), "readonly tag must appear");
}
#[test]
fn status_line_new_file_tag() {
let s = format_status_line_full("NORMAL", "newfile.txt", false, false, true, 0, 0, 1, 80);
assert!(s.contains("[New File]"), "new-file tag must appear");
}
#[test]
fn status_line_truncates_long_filename() {
let long = "some/very/long/path/to/a/deeply/nested/file.rs";
let s = format_status_line_full("NORMAL", long, false, false, false, 0, 0, 1, 30);
assert!(
s.contains('\u{2026}'),
"truncated filename must start with …"
);
}
#[test]
fn status_line_arg_parsing_plus_n() {
let s = format_status_line("NORMAL", "file.txt", false, 4, 0, 10, 60);
assert!(s.contains("5:1"));
}
#[test]
fn top_bar_height_is_one() {
assert_eq!(crate::app::TOP_BAR_HEIGHT, 1);
}
}