use hjkl_buffer::Viewport;
use hjkl_buffer_tui::{BufferView, DiagOverlay, Gutter, GutterNumbers};
use hjkl_engine::{Host, Query};
use hjkl_statusline::{
Bar, Color as SlColor, Segment as SlSegment, StatusTheme, Style as SlStyle, StyleExt,
dirty_segment, filename_segment, loading_segment, mode_segment, pending_segment,
search_count_segment, truncate_filename,
};
use hjkl_vim::VimEditorExt;
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};
use hjkl_holler_tui::{HollerLayout, render_active};
use hjkl_prompt_tui::{PromptTheme, build_prompt_line};
use hjkl_tabs::TabBar;
use hjkl_tabs_tui::{TabBarTheme, build_line as build_tab_line};
const GIT_MODIFIED: Color = Color::Rgb(0xd7, 0x87, 0x5f);
const GIT_STAGED: Color = Color::Rgb(0x87, 0xaf, 0x5f);
const GIT_DELETED: Color = Color::Rgb(0xd7, 0x5f, 0x5f);
const GIT_UNTRACKED: Color = Color::Rgb(0x5f, 0xaf, 0xaf);
const GIT_TEXT: Color = Color::Rgb(0x1c, 0x1c, 0x1c);
fn rgb(o: Option<(u8, u8, u8)>) -> Option<Color> {
o.map(|(r, g, b)| Color::Rgb(r, g, b))
}
fn ratatui_rgb_to_sl(c: Color) -> SlColor {
match c {
Color::Rgb(r, g, b) => SlColor::rgb(r, g, b),
_ => SlColor::rgb(0xff, 0xff, 0xff),
}
}
fn to_hjkl_color(c: Color) -> hjkl_theme::Color {
match c {
Color::Rgb(r, g, b) => hjkl_theme::Color::rgb(r, g, b),
_ => hjkl_theme::Color::rgb(0xff, 0xff, 0xff),
}
}
fn app_status_theme(app: &App) -> StatusTheme {
let ui = &app.theme.ui;
let mut t = StatusTheme::default();
t.bg = ratatui_rgb_to_sl(ui.surface_bg);
t.fg = ratatui_rgb_to_sl(ui.text);
t.fill_bg = ratatui_rgb_to_sl(ui.panel_bg);
t.mode_normal_bg = ratatui_rgb_to_sl(ui.mode_normal_bg);
t.mode_normal_fg = ratatui_rgb_to_sl(ui.on_accent);
t.mode_insert_bg = ratatui_rgb_to_sl(ui.mode_insert_bg);
t.mode_insert_fg = ratatui_rgb_to_sl(ui.on_accent);
t.mode_visual_bg = ratatui_rgb_to_sl(ui.mode_visual_bg);
t.mode_visual_fg = ratatui_rgb_to_sl(ui.on_accent);
t.dirty_fg = ratatui_rgb_to_sl(ui.status_dirty_marker);
t.readonly_fg = ratatui_rgb_to_sl(ui.text);
t.new_file_fg = ratatui_rgb_to_sl(ui.text);
t.recording_bg = ratatui_rgb_to_sl(ui.recording_bg);
t.recording_fg = ratatui_rgb_to_sl(ui.recording_fg);
t
}
pub(crate) fn build_normal_status_bar(app: &App, width: u16) -> Line<'static> {
let theme = app_status_theme(app);
let ui = &app.theme.ui;
let mode = app.mode_label();
let mut bar = Bar {
fill_style: SlStyle::default_style()
.bg(ratatui_rgb_to_sl(ui.panel_bg))
.fg(ratatui_rgb_to_sl(ui.text)),
..Default::default()
};
bar.left.push(mode_segment(mode, &theme));
{
let pc = app.active_editor().pending_count();
let po = app.active_editor().pending_op();
let po_str = po.map(|s| s.to_string());
if let Some(seg) = pending_segment(pc.map(|n| n as u64), po_str.as_deref(), &theme) {
bar.left.push(seg);
}
}
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 suffix = format!("{ro_tag}{new_tag}{disk_tag}{untracked_tag}");
let (row, col) = app.active_editor().cursor();
let line_count = app.active_editor().buffer().line_count() as usize;
let pos_content = format!(" {}:{} ", row + 1, col + 1);
let pct_content = {
let pct = ((row + 1) * 100).checked_div(line_count).unwrap_or(0);
format!(" {pct}% ")
};
let loading_label: 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 diag_count_content: 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 search_count_content: String = search_count(app)
.map(|(idx, total)| format!(" [{idx}/{total}] "))
.unwrap_or_default();
let dirty_content = if app.active().dirty { " ● " } else { "" };
let w = width as usize;
let mode_chars = mode.chars().count() + 2; let pending_chars = {
let pc = app.active_editor().pending_count();
let po = app.active_editor().pending_op();
match (pc, po) {
(Some(n), Some(op)) => format!(" {n}{op} ").chars().count(),
(Some(n), None) => format!(" {n} ").chars().count(),
(None, Some(op)) => format!(" {op} ").chars().count(),
(None, None) => 0,
}
};
let right_chars = pos_content.chars().count() + pct_content.chars().count();
let loading_chars = if loading_label.is_empty() {
0
} else {
loading_label.chars().count() + 2 };
let reserved = mode_chars
+ pending_chars
+ 2 + suffix.chars().count()
+ dirty_content.chars().count()
+ search_count_content.chars().count()
+ diag_count_content.chars().count()
+ loading_chars
+ right_chars;
let avail_for_name = w.saturating_sub(reserved);
let filename = truncate_filename(&raw_filename, avail_for_name);
bar.left.push(filename_segment(&filename, &suffix, &theme));
if let Some(seg) = dirty_segment(app.active().dirty, &theme) {
bar.left.push(seg);
}
if !search_count_content.is_empty() {
bar.left.push(search_count_segment(
0, 0, &theme,
));
if let Some(SlSegment::Text { content, .. }) = bar.left.last_mut() {
*content = search_count_content.clone().into();
}
}
if !diag_count_content.is_empty() {
let diags = &app.active().lsp_diags;
let diag_fg = if diags.iter().any(|d| d.severity == DiagSeverity::Error) {
theme.diag_error_fg
} else if diags.iter().any(|d| d.severity == DiagSeverity::Warning) {
theme.diag_warning_fg
} else if diags.iter().any(|d| d.severity == DiagSeverity::Info) {
theme.diag_info_fg
} else {
theme.diag_hint_fg
};
bar.left.push(SlSegment::Text {
content: diag_count_content.clone().into(),
style: SlStyle::default_style()
.bg(ratatui_rgb_to_sl(ui.surface_bg))
.fg(diag_fg),
});
}
if !loading_label.is_empty() {
bar.left.push(loading_segment(&loading_label, "", &theme));
if let Some(SlSegment::Text { content, .. }) = bar.left.last_mut() {
*content = format!(" {loading_label} ").into();
}
}
bar.right.push(SlSegment::Text {
content: pos_content.into(),
style: SlStyle::default_style()
.bg(ratatui_rgb_to_sl(ui.surface_bg))
.fg(ratatui_rgb_to_sl(ui.text)),
});
bar.right.push(SlSegment::Text {
content: pct_content.into(),
style: SlStyle::default_style()
.bg(ratatui_rgb_to_sl(ui.mode_normal_bg))
.fg(ratatui_rgb_to_sl(ui.on_accent))
.bold(),
});
hjkl_statusline_tui::to_line(&bar, width)
}
fn diag_severity_style(sev: DiagSeverity) -> Style {
Style::default()
.underline_color(diag_severity_fg(sev))
.add_modifier(Modifier::UNDERLINED)
}
fn diag_severity_fg(sev: DiagSeverity) -> Color {
match sev {
DiagSeverity::Error => Color::Red,
DiagSeverity::Warning => Color::Rgb(255, 165, 0), DiagSeverity::Info => Color::Green,
DiagSeverity::Hint => Color::Cyan,
}
}
fn diag_severity_rank(sev: DiagSeverity) -> u8 {
match sev {
DiagSeverity::Error => 0,
DiagSeverity::Warning => 1,
DiagSeverity::Info => 2,
DiagSeverity::Hint => 3,
}
}
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() > max {
format!("{}\u{2026}", s.chars().take(max).collect::<String>())
} else {
s.to_string()
}
}
fn row_in_viewport(row: usize, vp_top: usize, vp_bot: usize) -> bool {
row >= vp_top && row < vp_bot
}
fn build_diag_overlays(
slot: &crate::app::BufferSlot,
_ui: &crate::theme::UiTheme,
vp_top: usize,
vp_bot: usize,
) -> Vec<DiagOverlay> {
slot.lsp_diags
.iter()
.filter(|d| row_in_viewport(d.start_row, vp_top, vp_bot))
.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()
}
pub(crate) fn max_lnum_width(app: &App) -> u16 {
app.slots()
.iter()
.filter(|s| !s.is_explorer)
.map(|s| s.lnum_width())
.max()
.unwrap_or(0)
}
pub(crate) fn stable_gutter_extra(app: &App) -> (u16, u16) {
use hjkl_engine::types::SignColumnMode;
let mut sign_w = 0u16;
let mut fold_w = 0u16;
for slot in app.slots().iter().filter(|s| !s.is_explorer) {
let st = slot.settings();
let has_any_signs = !slot.diag_signs.is_empty()
|| !slot.diag_signs_lsp.is_empty()
|| !slot.git_signs.is_empty();
let sw = match st.signcolumn {
SignColumnMode::Yes => 1,
SignColumnMode::No => 0,
SignColumnMode::Auto => {
if has_any_signs {
1
} else {
0
}
}
};
sign_w = sign_w.max(sw);
let has_folds = !slot.buffer().folds().is_empty();
let fw = (st.foldcolumn.min(12) as u16).max(if has_folds { 1 } else { 0 });
fold_w = fold_w.max(fw);
}
if fold_w == 0 && app.window_folds.values().any(|f| !f.is_empty()) {
fold_w = 1;
}
(sign_w, fold_w)
}
pub(crate) fn rendered_gutter_width(app: &App, win_id: window::WindowId) -> u16 {
let Some(Some(win)) = app.windows.get(win_id) else {
return 0;
};
let slot_idx = win.slot;
let Some(slot) = app.slots().get(slot_idx) else {
return 0;
};
if slot.is_explorer {
return 0;
}
let (sign_w, fold_w) = stable_gutter_extra(app);
let own_lnum = app
.window_editors
.get(&win_id)
.map(|e| e.lnum_width())
.unwrap_or_else(|| slot.lnum_width());
let num_gw = if own_lnum > 0 { max_lnum_width(app) } else { 0 };
sign_w + num_gw + 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)
}
pub(crate) fn buffer_background_style(app: &App) -> Style {
if app.theme_transparent {
Style::default()
} else {
Style::default().bg(app.theme.ui.background)
}
}
fn split_rect(area: Rect, dir: window::SplitDir, ratio: f32) -> (Rect, Rect) {
match dir.axis() {
window::Axis::Row => {
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::Axis::Col => {
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.axis() {
window::Axis::Col => ("│", 1u16),
window::Axis::Row => ("─", 1u16),
};
let buf = frame.buffer_mut();
match dir.axis() {
window::Axis::Col => {
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::Axis::Row => {
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(window::rect_to_layout(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.axis() {
window::Axis::Col => {
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::Axis::Row => {
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(window::rect_to_layout(area));
}
let (slot_idx, is_focused) = {
let win = match app.windows[win_id].as_ref() {
Some(w) => w,
None => return, };
(win.slot, win_id == app.focused_window())
};
let mut area = area;
if app.slots()[slot_idx].is_explorer && area.width >= 2 {
area.x += 1;
area.width -= 2;
}
let win_settings = app
.window_editors
.get(&win_id)
.map(|e| e.settings().clone())
.unwrap_or_else(|| app.slots()[slot_idx].settings().clone());
let s = &win_settings;
let (w_cursor_row, w_is_blame) = app
.window_editors
.get(&win_id)
.map(|e| (e.buffer().cursor().row, e.is_blame()))
.unwrap_or_else(|| (app.slots()[slot_idx].buffer().cursor().row, false));
let (nu, rnu) = (s.number, s.relativenumber);
let (cul, cuc) = (s.cursorline, s.cursorcolumn);
let colorcolumn = s.colorcolumn.clone();
let list_active = s.list;
let listchars_owned = s.listchars.clone();
let indent_guides_enabled = s.indent_guides;
let indent_guide_char = s.indent_guide_char;
let indent_guide_shiftwidth = s.shiftwidth;
let indent_guide_tabstop = s.tabstop;
let is_explorer_slot = app.slots()[slot_idx].is_explorer;
let (sign_w, fold_w) = if is_explorer_slot {
(0, 0)
} else {
stable_gutter_extra(app)
};
let own_lnum = app
.window_editors
.get(&win_id)
.map(|e| e.lnum_width())
.unwrap_or_else(|| app.slots()[slot_idx].lnum_width());
let num_gw_for_text = if own_lnum > 0 { max_lnum_width(app) } else { 0 };
let lnum_pad = num_gw_for_text.saturating_sub(own_lnum);
let gw = sign_w + num_gw_for_text + fold_w;
let text_width = area.width.saturating_sub(gw);
if is_focused {
let tabstop = s.tabstop as u16;
if let Some(e) = app.window_editors.get_mut(&win_id) {
let vp = e.host_mut().viewport_mut();
vp.width = text_width;
vp.height = area.height;
vp.text_width = text_width;
vp.tab_width = tabstop;
e.set_viewport_height(area.height);
}
}
let cursor_row = w_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 = num_gw_for_text;
let gutter = if num_gw > 0 || sign_w > 0 || fold_w > 0 {
Some(Gutter {
width: num_gw,
style: Style::default().fg(app.theme.ui.gutter),
line_offset: 0,
numbers,
sign_column_width: sign_w,
fold_column_width: fold_w,
})
} else {
None
};
let mut viewport_owned = app
.window_editors
.get(&win_id)
.map(|e| *e.host().viewport())
.unwrap_or_default();
viewport_owned.width = text_width;
viewport_owned.height = area.height;
viewport_owned.text_width = text_width;
let target_top_row = viewport_owned.top_row;
let vp_top = app.scroll_anim_render_top(win_id).unwrap_or(target_top_row);
viewport_owned.top_row = vp_top;
let viewport_ref: &Viewport = &viewport_owned;
let in_prompt = app.command_field.is_some()
|| app.filter_field.is_some()
|| app.search_field.is_some()
|| app.picker.is_some()
|| app.explorer_git_discard_confirm.is_some();
let vp_bot = vp_top + area.height as usize;
let mut visible_signs: Vec<hjkl_buffer_tui::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 = if is_focused {
app.window_editors
.get(&win_id)
.and_then(|e| e.buffer_selection())
} else {
None
};
let buffer_spans: &[Vec<hjkl_buffer::Span>] = app
.window_editors
.get(&win_id)
.map(|e| e.buffer_spans())
.unwrap_or(&[]);
let search_pattern_owned = app
.window_editors
.get(&win_id)
.and_then(|e| e.search_state().pattern.clone());
let search_pattern = search_pattern_owned.as_ref();
let search_bg = if search_pattern.is_some() {
app.theme.ui.search_match_style()
} else {
Style::default()
};
let style_table = app
.window_editors
.get(&win_id)
.map(|e| e.style_table().to_owned())
.unwrap_or_default();
let resolver = move |id: u32| {
hjkl_engine_tui::style_to_ratatui(style_table.get(id as usize).copied().unwrap_or_default())
};
let show_cursor = is_focused && !in_prompt;
let cursor_line_style = if cul {
if show_cursor {
cursor_line_bg(&app.theme.ui)
} else {
Style::default().bg(app.theme.ui.cursor_line_inactive_bg)
}
} 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 indent_guide_active_col: Option<usize> =
if is_focused && indent_guides_enabled && indent_guide_shiftwidth > 0 {
let cursor_row = w_cursor_row;
let rope = app.slots()[slot_idx].buffer().rope();
let cursor_line = hjkl_buffer::rope_line_str(&rope, cursor_row);
let tab_width = indent_guide_tabstop.max(1);
let mut leading_vcols: usize = 0;
for ch in cursor_line.chars() {
match ch {
' ' => leading_vcols += 1,
'\t' => {
leading_vcols += tab_width - (leading_vcols % tab_width);
}
_ => break,
}
}
if leading_vcols >= indent_guide_shiftwidth {
let level = (leading_vcols - 1) / indent_guide_shiftwidth;
Some(level * indent_guide_shiftwidth)
} else {
None
}
} else {
None
};
let diag_overlays = build_diag_overlays(&app.slots()[slot_idx], &app.theme.ui, vp_top, vp_bot);
use hjkl_engine::types::DiagInlineMode;
const BLAME_IDLE_DELAY: std::time::Duration = std::time::Duration::from_millis(400);
use hjkl_buffer_tui::render::EolHint;
let comment_lead = app.active_comment_lead();
let eol_hints: Vec<EolHint> = {
let slot = &app.slots()[slot_idx];
let cursor_row = w_cursor_row;
let diag_mode = s.diagnostics_inline;
let mut hints: Vec<EolHint> = Vec::new();
if is_focused && diag_mode != DiagInlineMode::Off {
let mut by_row: std::collections::HashMap<usize, (DiagSeverity, &str)> =
std::collections::HashMap::new();
for d in &slot.lsp_diags {
if !row_in_viewport(d.start_row, vp_top, vp_bot) {
continue;
}
if diag_mode == DiagInlineMode::Current && d.start_row != cursor_row {
continue;
}
let msg = d.message.lines().next().unwrap_or("");
by_row
.entry(d.start_row)
.and_modify(|cur| {
if diag_severity_rank(d.severity) < diag_severity_rank(cur.0) {
*cur = (d.severity, msg);
}
})
.or_insert((d.severity, msg));
}
for (row, (severity, msg)) in by_row {
hints.push(EolHint {
row,
text: format!("{comment_lead} {}", truncate_chars(msg, 80)),
style: Style::default()
.fg(diag_severity_fg(severity))
.add_modifier(Modifier::ITALIC),
});
}
}
let blame_show = s.blame_inline
&& !w_is_blame
&& is_focused
&& app.blame_cursor_moved_at.elapsed() >= BLAME_IDLE_DELAY
&& !hints.iter().any(|h| h.row == cursor_row);
if blame_show && let Some(Some(info)) = slot.blame.get(cursor_row) {
let body = if info.is_uncommitted {
"You \u{00b7} Not Committed Yet".to_string()
} else {
format!(
"{} \u{00b7} {}",
info.author,
truncate_chars(&info.summary, 50)
)
};
hints.push(EolHint {
row: cursor_row,
text: format!("{comment_lead} {body}"),
style: Style::default().fg(app.theme.ui.non_text),
});
}
hints
};
let box_mode = w_is_blame && matches!(viewport_owned.wrap, hjkl_buffer::Wrap::None);
let blame_box_plan: Vec<hjkl_buffer_tui::render::BlameRow> = if box_mode {
let s = &app.slots()[slot_idx];
let vp_top = viewport_owned.top_row;
let line_count = s.buffer().line_count() as usize;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let buf = s.buffer();
crate::app::git_hunks::build_blame_box_plan(
&s.blame,
line_count,
|r| buf.is_row_hidden(r),
vp_top,
area.height as usize,
now,
)
} else {
Vec::new()
};
let explorer_conceals: Vec<hjkl_buffer_tui::Conceal> = if is_explorer_slot && !app.debug_mode {
use crate::app::explorer_reconcile::ID_SEP;
let buf_text = app.slots()[slot_idx].buffer().as_string();
buf_text
.lines()
.enumerate()
.filter_map(|(row, line)| {
let us_byte = line.find(ID_SEP)?;
Some(hjkl_buffer_tui::Conceal {
row,
start_byte: us_byte,
end_byte: line.len(),
replacement: String::new(),
})
})
.collect()
} else {
Vec::new()
};
let diff_filler_plan = app.diff_filler_plan(win_id);
let view = BufferView {
buffer: app.slots()[slot_idx].buffer(),
viewport: viewport_ref,
selection,
resolver: &resolver,
cursor_line_bg: cursor_line_style,
cursor_line_row: Some(w_cursor_row),
fold_line_bg: if is_explorer_slot {
Style::default()
} else {
Style::default().bg(app.theme.ui.fold_line_bg)
},
folds_override: if is_focused {
None
} else {
app.window_folds.get(&win_id).map(Vec::as_slice)
},
cursor_column_bg: cursor_column_style,
selection_bg: Style::default().bg(Color::Blue),
cursor_style: Style::default(),
gutter,
search_bg,
signs: &visible_signs,
conceals: &explorer_conceals,
spans: buffer_spans,
search_pattern,
non_text_style: Style::default().fg(app.theme.ui.non_text),
show_eob: app.slots()[slot_idx].features.end_of_buffer,
diag_overlays: &diag_overlays,
colorcolumn_cols: &cc_cols,
colorcolumn_style: cc_style,
listchars: if list_active {
Some(&listchars_owned)
} else {
None
},
indent_guides_enabled,
indent_guide_char,
indent_guide_shiftwidth,
indent_guide_fg: app.theme.ui.indent_guide_fg,
indent_guide_active_fg: app.theme.ui.indent_guide_active_fg,
indent_guide_active_col,
eol_hints: &eol_hints,
blame_plan: if box_mode {
Some(&blame_box_plan)
} else {
None
},
diff_filler: diff_filler_plan.as_ref(),
background: buffer_background_style(app),
};
frame.render_widget(view, area);
if is_explorer_slot
&& !app.debug_mode
&& let Some(ref pane) = app.explorer
&& pane.win_id == win_id
{
let buf = frame.buffer_mut();
let screen_top = area.y;
let screen_height = area.height as usize;
let text_x = area.x;
let vp_top_ex = vp_top;
let guide_fg = app.theme.ui.indent_guide_fg;
let (explorer_folds, buf_text): (Vec<hjkl_buffer::Fold>, String) =
if let Some(slot_idx) = app.slots().iter().position(|s| s.is_explorer) {
let b = app.slots()[slot_idx].buffer();
let folds = if is_focused {
b.folds()
} else {
app.window_folds
.get(&win_id)
.cloned()
.unwrap_or_else(|| b.folds())
};
(folds, b.as_string())
} else {
(Vec::new(), String::new())
};
let overlay_nodes =
crate::app::explorer::overlay_nodes_from_buffer(&buf_text, &pane.tree.root);
let git_map: std::collections::HashMap<&std::path::Path, hjkl_app::git::ExplorerGit> = pane
.tree
.nodes
.iter()
.filter_map(|n| n.git.map(|g| (n.path.as_path(), g)))
.collect();
let total_nodes = overlay_nodes.len();
let mut doc_row = vp_top_ex;
let mut screen_row_idx: usize = 0;
while doc_row < total_nodes && screen_row_idx < screen_height {
if explorer_folds.iter().any(|f| f.hides(doc_row)) {
doc_row += 1;
continue;
}
let buf_row = doc_row;
let screen_row = screen_top + screen_row_idx as u16;
if screen_row >= screen_top + area.height {
break;
}
screen_row_idx += 1;
doc_row += 1;
let Some(node) = overlay_nodes.get(buf_row).and_then(|o| o.as_ref()) else {
continue;
};
let node_git = git_map.get(node.path.as_path()).copied();
let right = area.x + area.width;
let is_expanded = app
.explorer
.as_ref()
.map(|ep| ep.tree.is_expanded(node.path.as_path()))
.unwrap_or(false);
let icon_ch = if node.is_dir {
hjkl_icons::dir_icon_for_path(&node.path, is_expanded, app.icon_mode)
} else {
hjkl_icons::file_icon_for_path(&node.path, app.icon_mode)
};
if node.depth == 0 {
let icon_abs = text_x;
if icon_abs < right
&& let Some(cell) = buf.cell_mut((icon_abs, screen_row))
{
cell.set_symbol(&icon_ch.to_string());
}
} else {
for (i, &has_sibling) in node.branches.iter().enumerate() {
let col = text_x + (i as u16) * 2;
if col < right
&& let Some(cell) = buf.cell_mut((col, screen_row))
{
let sym = if has_sibling { "│" } else { " " };
cell.set_symbol(sym);
cell.set_fg(guide_fg);
}
}
let connector_col = text_x + (node.branches.len() as u16) * 2;
if connector_col < right
&& let Some(cell) = buf.cell_mut((connector_col, screen_row))
{
let sym = if node.is_last { "└" } else { "├" };
cell.set_symbol(sym);
cell.set_fg(guide_fg);
}
let connector_col2 = connector_col + 1;
if connector_col2 < right
&& let Some(cell) = buf.cell_mut((connector_col2, screen_row))
{
cell.set_symbol("╴");
cell.set_fg(guide_fg);
}
let icon_abs = text_x + node.depth as u16 * 2;
if icon_abs < right
&& let Some(cell) = buf.cell_mut((icon_abs, screen_row))
{
cell.set_symbol(&icon_ch.to_string());
}
}
let (icon_col, name_col) = if node.depth == 0 {
(text_x, text_x + 2)
} else {
let guide_cols = node.branches.len() as u16 * 2;
let icon_off = guide_cols + 2; (text_x + icon_off, text_x + icon_off + 2)
};
let icon_color = if node.is_dir {
let name = node.path.file_name().and_then(|n| n.to_str());
rgb(hjkl_icons::dir_color(name))
} else {
rgb(hjkl_icons::file_color_for_path(&node.path))
}
.unwrap_or(app.theme.ui.text);
let git_bg = match node_git {
Some(hjkl_app::git::ExplorerGit::Modified) => Some(GIT_MODIFIED),
Some(hjkl_app::git::ExplorerGit::Staged) => Some(GIT_STAGED),
Some(hjkl_app::git::ExplorerGit::Deleted) => Some(GIT_DELETED),
Some(hjkl_app::git::ExplorerGit::Untracked) => Some(GIT_UNTRACKED),
None => None,
};
let clean_name_fg = if node.is_dir {
rgb(hjkl_icons::dir_color(
node.path.file_name().and_then(|n| n.to_str()),
))
} else {
rgb(hjkl_icons::file_color_for_path(&node.path))
}
.unwrap_or(app.theme.ui.text);
if icon_col < right
&& let Some(cell) = buf.cell_mut((icon_col, screen_row))
{
cell.set_fg(icon_color);
}
let indent_chars = name_col.saturating_sub(text_x) as usize;
let name_len = buf_text
.lines()
.nth(buf_row)
.map(|line| {
line.chars()
.skip(indent_chars)
.take_while(|&c| c != '\u{1F}')
.count() as u16
})
.unwrap_or(0);
let name_end = name_col.saturating_add(name_len).min(right);
for col in name_col..name_end {
if let Some(cell) = buf.cell_mut((col, screen_row)) {
match git_bg {
Some(bg) => {
cell.set_bg(bg);
cell.set_fg(GIT_TEXT);
}
None => {
cell.set_fg(clean_name_fg);
}
}
}
}
}
}
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 + fold_w;
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 app.is_diff_window(win_id) {
let classes = app.diff_line_classes(win_id);
if !classes.is_empty() {
let add_bg = Color::Rgb(32, 51, 32); let change_bg = Color::Rgb(32, 42, 60); let text_bg = Color::Rgb(48, 78, 110); let text_x = area.x + sign_w + num_gw + fold_w;
let text_right = area.x + area.width;
let screen_top = area.y;
let screen_height = area.height as usize;
let buf = frame.buffer_mut();
for (&line, class) in &classes {
if line < vp_top {
continue;
}
let off = match diff_filler_plan.as_ref() {
Some(p) => p.screen_offset(vp_top, line),
None => line - vp_top,
};
if off >= screen_height {
continue;
}
let screen_row = screen_top + off as u16;
let band_bg = match class.band {
crate::app::diff_mode::DiffBand::Add => add_bg,
crate::app::diff_mode::DiffBand::Change => change_bg,
};
for col in text_x..text_right {
if let Some(c) = buf.cell_mut((col, screen_row)) {
c.set_style(c.style().patch(Style::default().bg(band_bg)));
}
}
if !class.text_ranges.is_empty() {
let rope = hjkl_engine::Query::rope(app.slots()[slot_idx].buffer());
let lt = hjkl_buffer::rope_line_str(&rope, line);
let lt = lt.trim_end_matches('\n');
let len = lt.len();
for r in &class.text_ranges {
let start = r.start.min(len);
let end = r.end.min(len);
let cs = lt[..start].chars().count();
let ce = lt[..end].chars().count();
for ci in cs..ce {
let scol = text_x + ci as u16;
if scol >= text_right {
break;
}
if let Some(c) = buf.cell_mut((scol, screen_row)) {
c.set_style(c.style().patch(Style::default().bg(text_bg)));
}
}
}
}
}
}
}
if is_focused
&& let Some(cs) = app.confirming_substitute.as_ref()
&& cs.idx < cs.matches.len()
{
let m = &cs.matches[cs.idx];
let match_row = m.row as usize;
let vp_bot = vp_top + area.height as usize;
if match_row >= vp_top && match_row < vp_bot {
let screen_row = area.y + (match_row - vp_top) as u16;
let rope = hjkl_engine::Query::rope(app.slots()[slot_idx].buffer());
let line = hjkl_buffer::rope_line_str(&rope, match_row);
let line_no_nl = line.trim_end_matches('\n');
let char_start = line_no_nl[..m.byte_start as usize].chars().count();
let char_end = line_no_nl[..m.byte_end as usize].chars().count();
let text_x = area.x + sign_w + num_gw + fold_w;
let highlight_style = app
.theme
.ui
.search_match_style()
.add_modifier(Modifier::REVERSED);
let buf = frame.buffer_mut();
for ch_idx in char_start..char_end {
let screen_col = text_x + ch_idx as u16;
if screen_col >= area.x + area.width {
break;
}
if let Some(cell) = buf.cell_mut((screen_col, screen_row)) {
cell.set_style(cell.style().patch(highlight_style));
}
}
}
}
let base_text_x = area.x + sign_w + num_gw + fold_w;
let vp_bot_overlay = vp_top + area.height as usize;
let map_doc_to_screen = |pair_row: usize, pair_col: usize| -> Option<(u16, u16)> {
if box_mode {
let idx = blame_box_plan.iter().position(
|r| matches!(r, hjkl_buffer_tui::render::BlameRow::Buffer(d) if *d == pair_row),
)?;
Some((
base_text_x + hjkl_buffer_tui::render::BLAME_BOX_FRAME_LEFT + pair_col as u16,
area.y + idx as u16,
))
} else if pair_row >= vp_top && pair_row < vp_bot_overlay {
Some((
base_text_x + pair_col as u16,
area.y + (pair_row - vp_top) as u16,
))
} else {
None
}
};
if is_focused && let Some(pairs) = app.matchparen_cells() {
let match_paren_style = Style::default()
.bg(app.theme.ui.match_paren_bg)
.add_modifier(Modifier::BOLD | Modifier::REVERSED);
let right = area.x + area.width;
let buf = frame.buffer_mut();
for (pair_row, pair_col) in pairs {
if let Some((screen_col, screen_row)) = map_doc_to_screen(pair_row, pair_col)
&& screen_col < right
&& let Some(cell) = buf.cell_mut((screen_col, screen_row))
{
cell.set_style(cell.style().patch(match_paren_style));
}
}
}
if is_focused && let Some(tag_cells) = app.matchparen_tag_cells() {
let match_paren_style = Style::default()
.bg(app.theme.ui.match_paren_bg)
.add_modifier(Modifier::BOLD | Modifier::REVERSED);
let right = area.x + area.width;
let buf = frame.buffer_mut();
for (pair_row, pair_col) in tag_cells {
if let Some((screen_col, screen_row)) = map_doc_to_screen(pair_row, pair_col)
&& screen_col < right
&& let Some(cell) = buf.cell_mut((screen_col, screen_row))
{
cell.set_style(cell.style().patch(match_paren_style));
}
}
}
if let Some(hop) = app.hop.as_ref()
&& hop.win_id == win_id
{
let label_style = Style::default()
.fg(app.theme.ui.hop_label_fg)
.bg(app.theme.ui.hop_label_bg)
.add_modifier(Modifier::BOLD);
let right = area.x + area.width;
let bottom = area.y + area.height;
let targets: Vec<(usize, usize, String)> = hop
.targets
.iter()
.map(|t| (t.row, t.col, t.label.clone()))
.collect();
let buf = frame.buffer_mut();
for (doc_row, doc_col, label) in &targets {
if let Some((screen_col, screen_row)) = map_doc_to_screen(*doc_row, *doc_col)
&& screen_col < right
&& screen_row < bottom
{
for (i, ch) in label.chars().enumerate() {
let x = screen_col + i as u16;
if x >= right {
break;
}
if let Some(cell) = buf.cell_mut((x, screen_row)) {
cell.set_char(ch);
cell.set_style(label_style);
}
}
}
}
}
if show_cursor
&& let Some((cx, cy)) = app.active_editor_mut().cursor_screen_pos(
area.x,
area.y,
area.width,
area.height,
sign_w + fold_w + lnum_pad,
)
{
let pos = if box_mode {
let cur = w_cursor_row;
blame_box_plan
.iter()
.position(
|r| matches!(r, hjkl_buffer_tui::render::BlameRow::Buffer(d) if *d == cur),
)
.map(|idx| {
(
cx + hjkl_buffer_tui::render::BLAME_BOX_FRAME_LEFT,
area.y + idx as u16,
)
})
} else if let Some(p) = diff_filler_plan.as_ref() {
let cur = w_cursor_row;
if cur >= vp_top {
let off = p.screen_offset(vp_top, cur);
if (off as u16) < area.height {
Some((cx, area.y + off as u16))
} else {
None
}
} else {
Some((cx, cy))
}
} else {
let shift: i64 = match vp_top.cmp(&target_top_row) {
std::cmp::Ordering::Equal => 0,
std::cmp::Ordering::Less => app.slots()[slot_idx].buffer().screen_rows_between(
viewport_ref,
vp_top,
target_top_row - 1,
) as i64,
std::cmp::Ordering::Greater => {
-(app.slots()[slot_idx].buffer().screen_rows_between(
viewport_ref,
target_top_row,
vp_top - 1,
) as i64)
}
};
let adj_cy = cy as i64 + shift;
if adj_cy >= area.y as i64 && adj_cy < (area.y as i64 + area.height as i64) {
Some((cx, adj_cy as u16))
} else {
None
}
};
if let Some((cx, cy)) = pos {
let shape = app.active_editor().host().cursor_shape();
match shape {
hjkl_engine::CursorShape::Bar => {
frame.set_cursor_position((cx, cy));
}
hjkl_engine::CursorShape::Block => {
if let Some(cell) = frame.buffer_mut().cell_mut((cx, cy)) {
cell.set_style(Style::default().add_modifier(Modifier::REVERSED));
}
}
hjkl_engine::CursorShape::Underline => {
if let Some(cell) = frame.buffer_mut().cell_mut((cx, cy)) {
cell.set_style(Style::default().add_modifier(Modifier::UNDERLINED));
}
}
}
}
}
}
fn completion_popup(frame: &mut Frame, app: &App, buf_area: Rect) {
let completion = match app.completion.as_ref() {
Some(p) => p,
None => return,
};
let fw = app.focused_window();
let win_rect = app.windows[fw].as_ref().and_then(|w| w.last_rect);
let (base_x, base_y) = win_rect
.map(|r| (r.x, r.y))
.unwrap_or((buf_area.x, buf_area.y));
let focused_editor = app.window_editor(fw);
let vp = focused_editor.host().viewport();
let vp_top = vp.top_row;
let own_lnum = focused_editor.lnum_width();
let eff_lnum = if own_lnum > 0 { max_lnum_width(app) } else { 0 };
let (sign_w, fold_w) = stable_gutter_extra(app);
let gw = eff_lnum + sign_w + fold_w;
let cursor_row = completion.anchor_row.saturating_sub(vp_top) as u16;
let cursor_col = completion.anchor_col as u16 + gw;
let anchor = Rect {
x: base_x + cursor_col,
y: base_y + cursor_row,
width: 1,
height: 1,
};
let ui = &app.theme.ui;
let theme = hjkl_completion_tui::CompletionTheme::new(
to_hjkl_color(ui.border_active),
to_hjkl_color(ui.picker_selection_bg),
to_hjkl_color(ui.text),
to_hjkl_color(ui.text_dim),
);
hjkl_completion_tui::popup(frame, completion, &theme, anchor, buf_area);
}
pub fn frame(frame: &mut Frame, app: &mut App) {
let area = frame.area();
app.last_frame_rect = Some(area);
let real_slots = (0..app.slots().len())
.filter(|&idx| !app.slot_is_special(idx))
.count();
let show_top_bar = app.tabs.len() > 1 || real_slots > 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;
}
if let Some(tb) = top_bar_area {
top_bar(frame, app, tb);
}
let dock_win = app.left_dock.as_ref().map(|d| d.win_id);
let main_area = if let Some(dock_win) = dock_win {
let total_w = buf_area.width;
let dock_w =
crate::app::dock::clamp_dock_width(app.config.explorer.width, total_w).min(total_w);
let (dock_rect, sep_rect, rest) = if dock_w >= 2 && buf_area.width > dock_w {
let content = Rect {
x: buf_area.x,
y: buf_area.y,
width: dock_w - 1,
height: buf_area.height,
};
let sep = Rect {
x: buf_area.x + dock_w - 1,
y: buf_area.y,
width: 1,
height: buf_area.height,
};
let rest = Rect {
x: buf_area.x + dock_w,
y: buf_area.y,
width: buf_area.width - dock_w,
height: buf_area.height,
};
(content, Some(sep), rest)
} else {
let w = dock_w.min(buf_area.width);
let content = Rect {
x: buf_area.x,
y: buf_area.y,
width: w,
height: buf_area.height,
};
let rest = Rect {
x: buf_area.x + w,
y: buf_area.y,
width: buf_area.width - w,
height: buf_area.height,
};
(content, None, rest)
};
render_window(frame, app, dock_rect, dock_win);
if let Some(sep) = sep_rect {
draw_separator(frame, sep, window::SplitDir::Vertical, app.theme.ui.border);
}
rest
} else {
buf_area
};
let bottom_dock_win = app.bottom_dock.as_ref().map(|d| d.win_id);
let main_area = if let Some(dock_win) = bottom_dock_win {
let total_h = main_area.height;
let dock_h =
crate::app::dock::clamp_dock_height(app.config.panel.height, total_h).min(total_h);
let (dock_rect, sep_rect, rest) = if dock_h >= 2 && main_area.height > dock_h {
let sep = Rect {
x: main_area.x,
y: main_area.y + main_area.height - dock_h,
width: main_area.width,
height: 1,
};
let content = Rect {
x: main_area.x,
y: sep.y + 1,
width: main_area.width,
height: dock_h - 1,
};
let rest = Rect {
x: main_area.x,
y: main_area.y,
width: main_area.width,
height: main_area.height - dock_h,
};
(content, Some(sep), rest)
} else {
let h = dock_h.min(main_area.height);
let content = Rect {
x: main_area.x,
y: main_area.y + main_area.height - h,
width: main_area.width,
height: h,
};
let rest = Rect {
x: main_area.x,
y: main_area.y,
width: main_area.width,
height: main_area.height - h,
};
(content, None, rest)
};
render_window(frame, app, dock_rect, dock_win);
if let Some(sep) = sep_rect {
draw_separator(
frame,
sep,
window::SplitDir::Horizontal,
app.theme.ui.border,
);
}
rest
} else {
main_area
};
let mut layout = app.take_layout();
render_layout(frame, app, main_area, &mut layout);
app.restore_layout(layout);
status_line(frame, app, status_area);
if app.picker.is_some() {
picker_overlay(frame, app, buf_area);
}
if app.completion.is_some() {
if app.command_field.is_some() {
command_completion_popup(frame, app, status_area, buf_area);
} else {
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 {
crate::menu::render(frame, menu, area, &crate::menu::MenuTheme::default());
}
if let Some(ref popup) = app.hover_popup {
let hover_theme = hjkl_hover_tui::HoverTheme::new(
app.theme.ui.border_active,
app.theme.ui.panel_bg,
hjkl_markdown_tui::MdTheme::default(),
);
hjkl_hover_tui::render(frame, popup, &hover_theme, frame.area());
}
let holler_layout = HollerLayout::default();
render_active(
frame,
area,
&app.bus,
&holler_layout,
std::time::SystemTime::now(),
);
}
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).bg(ui.surface_bg);
let sep_style = Style::default().fg(ui.border);
let show_tabs = app.tabs.len() > 1;
let real_slot_count = (0..app.slots().len())
.filter(|&idx| !app.slot_is_special(idx))
.count();
let show_buffers = real_slot_count > 1;
let total_width = area.width as usize;
let mut tab_bar: TabBar<usize> = TabBar::new();
if show_tabs {
let tabline_icons = app.active_editor().settings().tabline_icons;
for (i, layout_tab) in app.tabs.iter().enumerate() {
let slot_idx = app.windows[layout_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 title = format!("{}: {} {}", i + 1, base_name, crate::app::TAB_CLOSE_GLYPH);
tab_bar.open(i, title);
let tab_dirty = layout_tab.layout.leaves().iter().any(|&wid| {
app.windows[wid]
.as_ref()
.map(|w| app.slots()[w.slot].dirty)
.unwrap_or(false)
});
if let Some(t) = tab_bar.tabs.last_mut() {
t.dirty = tab_dirty;
if tabline_icons {
let glyph = match slot.filename.as_deref() {
Some(p) => hjkl_icons::file_icon_for_path(p, app.icon_mode),
None => hjkl_icons::file_icon(None, app.icon_mode),
};
t.icon = Some(glyph.to_string());
t.icon_color = slot
.filename
.as_deref()
.and_then(hjkl_icons::file_color_for_path);
}
}
}
tab_bar.focus(&app.active_tab);
}
let tab_theme = TabBarTheme::new(
ui.on_accent,
ui.mode_normal_bg,
ui.text_dim,
ui.surface_bg,
ui.border,
ui.border,
);
let tab_line = if show_tabs {
build_tab_line(area.width, &tab_bar, &tab_theme)
} else {
hjkl_tabs_tui::build_line(0, &tab_bar, &tab_theme)
};
let tabs_total_len: usize = tab_line
.spans
.iter()
.map(|s| s.content.chars().count())
.sum();
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 {
let mut first = true;
for (i, slot) in app.slots().iter().enumerate() {
if app.slot_is_special(i) {
continue;
}
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, crate::app::TAB_CLOSE_GLYPH)
} else {
format!(" {} {} ", base_name, crate::app::TAB_CLOSE_GLYPH)
};
let sep_len = if first { 0 } else { 1 };
let entry_width = sep_len + label.chars().count();
if buf_used + entry_width > buf_budget {
if buf_used < buf_budget {
buf_spans.push(Span::styled("…".to_string(), sep_style));
}
break;
}
if !first {
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;
first = false;
}
}
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)));
}
all_spans.extend(tab_line.spans);
}
let paragraph = Paragraph::new(Line::from(all_spans));
frame.render_widget(paragraph, area);
}
fn prompt_theme(ui: &crate::theme::UiTheme) -> PromptTheme {
PromptTheme::new(
ui.form_insert_bg,
ui.form_normal_bg,
ui.text,
ui.form_tag_insert_fg,
ui.form_tag_normal_fg,
ui.panel_bg,
ui.text,
ui.picker_selection_bg,
)
}
fn command_completion_popup(frame: &mut Frame, app: &App, status_area: Rect, viewport: Rect) {
let completion = match app.completion.as_ref() {
Some(p) => p,
None => return,
};
let cursor_col: u16 = if let Some(ref field) = app.command_field {
let (_, col) = field.cursor();
1u16 + col as u16
} else {
1
};
let anchor = Rect {
x: status_area.x + cursor_col.min(status_area.width.saturating_sub(1)),
y: status_area.y,
width: 1,
height: 1,
};
let ui = &app.theme.ui;
let theme = hjkl_completion_tui::CompletionTheme::new(
to_hjkl_color(ui.border_active),
to_hjkl_color(ui.picker_selection_bg),
to_hjkl_color(ui.text),
to_hjkl_color(ui.text_dim),
);
hjkl_completion_tui::popup(frame, completion, &theme, anchor, viewport);
}
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));
}
}
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 pattern_str = pat.as_str();
let buf = app.active_editor().buffer();
let buffer_id = app.active().buffer_id;
let dirty_gen = buf.dirty_gen();
let cursor = app.active_editor().cursor();
if let Some(cached) = app.search_count_cache.borrow().as_ref()
&& cached.buffer_id == buffer_id
&& cached.dirty_gen == dirty_gen
&& cached.cursor == cursor
&& cached.pattern == pattern_str
{
return cached.result;
}
let (cursor_row, cursor_col) = cursor;
let cursor_byte_in_row = {
let rope = buf.rope();
if cursor_row < rope.len_lines() {
let line = hjkl_buffer::rope_line_str(&rope, cursor_row);
line.char_indices()
.nth(cursor_col)
.map(|(b, _)| b)
.unwrap_or(line.len())
} else {
0
}
};
let content = buf.content_joined();
let cursor_global_byte = {
let rope = buf.rope();
let row = cursor_row.min(rope.len_lines());
rope.line_to_byte(row) + cursor_byte_in_row
};
let mut total = 0usize;
let mut current_idx = 0usize;
for m in pat.find_iter(&content) {
total += 1;
if m.start() <= cursor_global_byte {
current_idx = total;
}
if total >= MATCH_CAP {
break;
}
}
let result = if total == 0 {
None
} else {
Some((current_idx, total))
};
*app.search_count_cache.borrow_mut() = Some(crate::app::SearchCountCache {
buffer_id,
dirty_gen,
cursor,
pattern: pattern_str.to_string(),
result,
});
result
}
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;
let theme = prompt_theme(&app.theme.ui);
return (
build_prompt_line(&content, field.coarse_mode(), &theme, width),
Some(cursor_col),
);
}
if let Some(ref field) = app.filter_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;
let theme = prompt_theme(&app.theme.ui);
return (
build_prompt_line(&content, field.coarse_mode(), &theme, 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;
let theme = prompt_theme(&app.theme.ui);
return (
build_prompt_line(&content, field.coarse_mode(), &theme, width),
Some(cursor_col),
);
}
if let Some(pr) = app.pending_recovery.as_ref() {
let content = format!(
"E325: swap file found (written {} ago). Recover? [y/N/q]",
pr.written_ago
);
let padded = format!("{content:<width$}", width = width as usize);
return (
Line::from(vec![Span::styled(
padded,
Style::default()
.bg(app.theme.ui.search_bg)
.fg(app.theme.ui.search_fg)
.add_modifier(Modifier::BOLD),
)]),
None,
);
}
if let Some(pdc) = app.pending_disk_change.as_ref() {
let name = pdc
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| pdc.path.to_string_lossy().into_owned());
let content =
format!("W12: \"{name}\" changed on disk since editing — [k]eep / [r]eload / [d]iff");
let padded = format!("{content:<width$}", width = width as usize);
return (
Line::from(vec![Span::styled(
padded,
Style::default()
.bg(app.theme.ui.search_bg)
.fg(app.theme.ui.search_fg)
.add_modifier(Modifier::BOLD),
)]),
None,
);
}
if let Some(ref path) = app.explorer_git_discard_confirm {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
let content = format!("Discard changes to {name}? (y/n)");
let padded = format!("{content:<width$}", width = width as usize);
return (
Line::from(vec![Span::styled(
padded,
Style::default()
.bg(app.theme.ui.search_bg)
.fg(app.theme.ui.search_fg)
.add_modifier(Modifier::BOLD),
)]),
None,
);
}
if let Some(cs) = app.confirming_substitute.as_ref() {
let rep = if cs.idx < cs.matches.len() {
cs.matches[cs.idx].replacement.as_str()
} else {
""
};
let content = format!("replace with \"{rep}\"? (y/n/a/q/l)");
let padded = format!("{content:<width$}", width = width as usize);
return (
Line::from(vec![Span::styled(
padded,
Style::default()
.bg(app.theme.ui.search_bg)
.fg(app.theme.ui.search_fg)
.add_modifier(Modifier::BOLD),
)]),
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,
);
}
(build_normal_status_bar(app, width), 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.chars().count() <= 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_byte = filename
.char_indices()
.rev()
.nth(keep.saturating_sub(1))
.map(|(byte_idx, _)| byte_idx)
.unwrap_or(0);
format!("\u{2026}{}", &filename[start_byte..])
};
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)
}
#[cfg(test)]
pub(crate) fn status_line_text(app: &App, width: u16) -> String {
let (line, _cursor_col) = build_status_line(app, width);
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("")
}
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),
};
if let Some(picker) = app.picker.as_ref() {
hjkl_picker_tui::preview_pane(frame, picker, app, &theme, right);
}
}
}
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 = hjkl_engine_tui::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 popup = match app.info_popup.as_ref() {
Some(p) => p,
None => return,
};
let theme = hjkl_info_popup_tui::InfoPopupTheme::new(app.theme.ui.border_active);
hjkl_info_popup_tui::render(frame, popup, &theme, buf_area);
}
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 =
hjkl_which_key::entries_for(app.ctx_keymap(), hjkl_vim::Mode::Normal, &pending, leader);
if entries.is_empty() {
return;
}
let ui = &app.theme.ui;
let popup_layout = hjkl_which_key::layout(&entries, buf_area.width);
let header_label = if pending.is_empty() {
"root".to_string()
} else {
hjkl_keymap::Chord(pending.clone()).to_notation(leader)
};
let theme = hjkl_which_key_tui::PopupTheme::new(
ratatui_rgb_to_sl(ui.border_active),
ratatui_rgb_to_sl(ui.text_dim),
);
hjkl_which_key_tui::render(frame, &popup_layout, &header_label, &theme, buf_area);
}
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_truncates_multibyte_filename_no_panic() {
let name = "éàüöïâ.rs";
let s = format_status_line_full("NORMAL", name, false, false, false, 0, 0, 1, 30);
let _ = s; }
#[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);
}
#[test]
fn buffer_background_honours_transparent_flag() {
use crate::app::App;
use ratatui::style::{Color, Style};
let mut cfg = hjkl_app::config::Config::default();
cfg.theme.transparent = false;
let app = App::new(None, false, None, None)
.unwrap()
.with_config(cfg.clone());
assert!(!app.theme_transparent);
assert_eq!(
buffer_background_style(&app),
Style::default().bg(app.theme.ui.background),
"opaque config must fill with the theme background"
);
assert!(matches!(
buffer_background_style(&app).bg,
Some(Color::Rgb(_, _, _))
));
cfg.theme.transparent = true;
let app_t = App::new(None, false, None, None).unwrap().with_config(cfg);
assert!(app_t.theme_transparent);
assert_eq!(
buffer_background_style(&app_t),
Style::default(),
"transparent config must leave the background unpainted (terminal shows through)"
);
assert_eq!(buffer_background_style(&app_t).bg, None);
}
#[test]
fn explorer_file_name_coloring_is_uniform() {
use crate::app::App;
use crate::keymap_actions::AppAction;
use ratatui::{Terminal, backend::TestBackend};
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("a").join("b").join("c")).unwrap();
std::fs::write(
tmp.path().join("a").join("b").join("c").join("widget.rs"),
b"x",
)
.unwrap();
let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());
let mut app = App::new(Some("a/b/c/widget.rs".into()), false, None, None).unwrap();
app.dispatch_action(AppAction::ToggleExplorer, 1);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let text_color = app.theme.ui.text;
let name: Vec<char> = "widget.rs".chars().collect();
let n = name.len() as u16;
let cell_sym = |x: u16, y: u16| -> String {
buf.cell((x, y))
.map(|c| c.symbol().to_string())
.unwrap_or_default()
};
let mut found: Option<(u16, u16)> = None;
'scan: for y in 0..24u16 {
for x in 0..(80 - n) {
if (0..n).all(|i| cell_sym(x + i, y) == name[i as usize].to_string()) {
found = Some((x, y));
break 'scan;
}
}
}
let (sx, sy) = found.expect("widget.rs must be rendered in the explorer");
let _ = text_color;
let icon_fg = buf.cell((sx - 2, sy)).unwrap().fg;
assert_ne!(icon_fg, Color::Reset, "icon should be filetype-colored");
let fgs: Vec<Color> = (0..n).map(|i| buf.cell((sx + i, sy)).unwrap().fg).collect();
assert!(
fgs.iter().all(|&f| f == icon_fg),
"every cell of the name must share the filetype color (no uncolored/white \
cells); icon_fg={icon_fg:?} name fgs={fgs:?}"
);
}
#[test]
fn diffsplit_paints_change_band_and_difftext() {
use crate::app::App;
use ratatui::{Terminal, backend::TestBackend};
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.txt");
let b = tmp.path().join("b.txt");
std::fs::write(&a, "alpha\nbeta\ngamma\n").unwrap();
std::fs::write(&b, "alpha\nBETA\ngamma\n").unwrap();
let mut app = App::new(Some(a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("diffsplit {}", b.display()));
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let change_bg = Color::Rgb(32, 42, 60);
let text_bg = Color::Rgb(48, 78, 110);
let mut has_change = false;
let mut has_text = false;
for y in 0..24u16 {
for x in 0..80u16 {
if let Some(c) = buf.cell((x, y)) {
if c.bg == change_bg {
has_change = true;
}
if c.bg == text_bg {
has_text = true;
}
}
}
}
assert!(has_change, "a DiffChange band must be painted");
assert!(has_text, "a DiffText char highlight must be painted");
}
#[test]
fn diffsplit_filler_aligns_shared_line() {
use crate::app::App;
use ratatui::{Terminal, backend::TestBackend};
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.txt");
let b = tmp.path().join("b.txt");
std::fs::write(&a, "l0\nl1\nl2\nl3\nl4\nl5\nzebra\n").unwrap();
std::fs::write(&b, "l0\nl1\nins\nl2\nl3\nl4\nl5\nzebra\n").unwrap();
let mut app = App::new(Some(a.clone()), false, None, None).unwrap();
app.dispatch_ex(&format!("diffsplit {}", b.display()));
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let cell_sym = |x: u16, y: u16| -> String {
buf.cell((x, y))
.map(|c| c.symbol().to_string())
.unwrap_or_default()
};
let needle: Vec<char> = "zebra".chars().collect();
let n = needle.len() as u16;
let mut gamma_rows = Vec::new();
for y in 0..24u16 {
for x in 0..(80 - n) {
if (0..n).all(|i| cell_sym(x + i, y) == needle[i as usize].to_string()) {
gamma_rows.push(y);
}
}
}
assert_eq!(
gamma_rows.len(),
2,
"gamma must render once per diff window; got {gamma_rows:?}"
);
assert_eq!(
gamma_rows[0], gamma_rows[1],
"filler must align `gamma` on the same screen row in both windows"
);
let filler_bg = Color::Rgb(60, 32, 32);
let has_filler = (0..24u16)
.any(|y| (0..80u16).any(|x| buf.cell((x, y)).map(|c| c.bg) == Some(filler_bg)));
assert!(has_filler, "a DiffDelete filler row must be painted");
}
#[test]
fn row_in_viewport_bounds() {
assert!(!row_in_viewport(4, 5, 10), "row before vp_top is out");
assert!(row_in_viewport(5, 5, 10), "vp_top is inclusive");
assert!(row_in_viewport(9, 5, 10), "last visible row is inclusive");
assert!(!row_in_viewport(10, 5, 10), "vp_bot is exclusive");
assert!(!row_in_viewport(500, 5, 10), "far off-screen row is out");
}
#[test]
fn build_diag_overlays_clips_to_viewport() {
use crate::app::{App, LspDiag};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("diag_clip.txt");
let content: String = (0..500).map(|i| format!("line {i}\n")).collect();
std::fs::write(&path, &content).unwrap();
let mut app = App::new(Some(path), false, None, None).unwrap();
let mk = |row: usize, msg: &str| LspDiag {
start_row: row,
start_col: 0,
end_row: row,
end_col: 3,
severity: DiagSeverity::Error,
message: msg.to_string(),
source: None,
code: None,
};
app.slots_mut()[0].lsp_diags = vec![
mk(0, "top edge, in range"),
mk(5, "middle, in range"),
mk(49, "bottom edge, in range (vp_bot exclusive at 50)"),
mk(50, "just past vp_bot, out"),
mk(400, "far off-screen, out"),
];
let overlays = build_diag_overlays(&app.slots()[0], &app.theme.ui, 0, 50);
let mut rows: Vec<usize> = overlays.iter().map(|o| o.row).collect();
rows.sort_unstable();
assert_eq!(
rows,
vec![0, 5, 49],
"only diagnostics with start_row in [0, 50) may produce an overlay"
);
let overlays2 = build_diag_overlays(&app.slots()[0], &app.theme.ui, 400, 450);
let rows2: Vec<usize> = overlays2.iter().map(|o| o.row).collect();
assert_eq!(rows2, vec![400]);
}
#[test]
fn visible_diagnostic_eol_hint_still_renders() {
use crate::app::{App, LspDiag};
use ratatui::{Terminal, backend::TestBackend};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("diag_eol.rs");
let content: String = std::iter::once("let x = 1;\n".to_string())
.chain((0..500).map(|i| format!("// filler {i}\n")))
.collect();
std::fs::write(&path, &content).unwrap();
let mut app = App::new(Some(path), false, None, None).unwrap();
app.slots_mut()[0].lsp_diags = vec![
LspDiag {
start_row: 0,
start_col: 0,
end_row: 0,
end_col: 1,
severity: DiagSeverity::Error,
message: "visible unused variable".to_string(),
source: None,
code: None,
},
LspDiag {
start_row: 450,
start_col: 0,
end_row: 450,
end_col: 1,
severity: DiagSeverity::Error,
message: "offscreen diagnostic".to_string(),
source: None,
code: None,
},
];
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let mut rendered = String::new();
for y in 0..24u16 {
for x in 0..80u16 {
if let Some(c) = buf.cell((x, y)) {
rendered.push_str(c.symbol());
}
}
}
assert!(
rendered.contains("visible unused variable"),
"the cursor-row diagnostic's EOL hint must still render: {rendered:?}"
);
assert!(
!rendered.contains("offscreen diagnostic"),
"the far off-screen diagnostic must never reach the screen buffer"
);
}
#[test]
fn screen_rect_is_real_terminal_rect_with_split() {
use crate::app::App;
use crate::app::mouse::{Zone, hit_test_zone};
use ratatui::{Terminal, backend::TestBackend};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("split_screen_rect.txt");
std::fs::write(&path, "l0\nl1\nl2\nl3\nl4\n").unwrap();
let mut app = App::new(Some(path), false, None, None).unwrap();
app.dispatch_ex("sp");
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
let screen = app.screen_rect();
assert_eq!(
(screen.width, screen.height),
(80, 24),
"screen_rect() must report the real 80x24 terminal, not the \
focused pane's shrunken dims"
);
let zone = hit_test_zone(&app, 10, 23);
assert_eq!(
zone,
Zone::StatusLine,
"row 23 (the real bottom row) must hit-test as the status line; got {zone:?}"
);
}
#[test]
fn completion_popup_anchors_to_scrolled_window_not_stale_slot_viewport() {
use crate::app::App;
use crate::completion::{Completion, CompletionItem};
use ratatui::{Terminal, backend::TestBackend};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("completion_scroll.txt");
let content: String = (0..100).map(|i| format!("line{i}\n")).collect();
std::fs::write(&path, &content).unwrap();
let mut app = App::new(Some(path), false, None, None).unwrap();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
let fw = app.focused_window();
if let Some(e) = app.window_editors.get_mut(&fw) {
e.host_mut().viewport_mut().top_row = 50;
}
app.completion = Some(Completion::new(
55,
0,
vec![CompletionItem::new("uniqueitemlabel")],
));
terminal.draw(|f| frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let mut found_row: Option<u16> = None;
'scan: for y in 0..24u16 {
for x in 0..80u16 {
if let Some(c) = buf.cell((x, y))
&& c.symbol() == "u"
{
let mut s = String::new();
for i in 0..15u16 {
if let Some(cc) = buf.cell((x + i, y)) {
s.push_str(cc.symbol());
}
}
if s.starts_with("uniqueitemlabe") {
found_row = Some(y);
break 'scan;
}
}
}
}
let row = found_row.expect(
"completion item must render somewhere on the 24-row screen — \
pre-fix it anchored off-screen using the stale slot viewport",
);
assert!(
row <= 10,
"completion popup must anchor NEAR the scrolled cursor row \
(doc row 55, window top_row 50 → screen row ~5); got row {row}, \
too far down to be anchored correctly"
);
}
#[test]
fn scroll_anim_render_top_reaches_the_text_renderer() {
use crate::app::{App, ScrollAnim};
use ratatui::{Terminal, backend::TestBackend};
use std::time::{Duration, Instant};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("scroll_anim.txt");
let content: String = (0..200).map(|i| format!("line{i}\n")).collect();
std::fs::write(&path, &content).unwrap();
let mut app = App::new(Some(path), false, None, None).unwrap();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| frame(f, &mut app)).unwrap();
let fw = app.focused_window();
if let Some(e) = app.window_editors.get_mut(&fw) {
e.host_mut().viewport_mut().top_row = 100;
}
app.scroll_anim = Some(ScrollAnim {
win_id: fw,
start_top: 0,
target_top: 100,
started_at: Instant::now() - Duration::from_secs(5),
duration: Duration::from_secs(10),
});
let expected_top = app
.scroll_anim_render_top(fw)
.expect("mid-flight anim must return Some");
assert!(
expected_top > 0 && expected_top < 100,
"test setup sanity: expected_top must be strictly between the \
anim's start (0) and target (100); got {expected_top}"
);
terminal.draw(|f| frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let mut rows: Vec<String> = Vec::with_capacity(24);
for y in 0..24u16 {
let mut row = String::new();
for x in 0..80u16 {
if let Some(c) = buf.cell((x, y)) {
row.push_str(c.symbol());
}
}
rows.push(row);
}
let rendered = rows.join("\n");
let needle = format!("line{expected_top}");
assert!(
rendered.contains(&needle),
"rendered text must show {needle:?} (the line at the ANIMATED \
top) somewhere on screen — pre-fix, text always drew from the \
target top (\"line100\") regardless of the animation. \
rendered:\n{rendered}"
);
assert!(
!rows[0].trim_start().starts_with("line100"),
"the top screen row must show the animated line, not the \
target's — got top row {:?}",
rows[0]
);
}
}