use crate::profile::{GROUP_USER, PhaseTime, ProfileReport, RequireNode, is_group_name};
use anyhow::Result;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame,
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation,
ScrollbarState, Table, TableState,
},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SortKey {
Load,
Init,
Trig,
Total,
Files,
Name,
}
impl SortKey {
fn label(&self) -> &'static str {
match self {
SortKey::Load => "load",
SortKey::Init => "init",
SortKey::Trig => "trig",
SortKey::Total => "total",
SortKey::Files => "files",
SortKey::Name => "name",
}
}
fn next(self) -> Self {
match self {
SortKey::Load => SortKey::Init,
SortKey::Init => SortKey::Trig,
SortKey::Trig => SortKey::Total,
SortKey::Total => SortKey::Files,
SortKey::Files => SortKey::Name,
SortKey::Name => SortKey::Load,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Table,
Detail,
}
impl Focus {
pub fn toggle(self) -> Self {
match self {
Self::Table => Self::Detail,
Self::Detail => Self::Table,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequireTreeSort {
ByTime,
Chronological,
}
impl RequireTreeSort {
fn toggle(self) -> Self {
match self {
Self::ByTime => Self::Chronological,
Self::Chronological => Self::ByTime,
}
}
fn label(self) -> &'static str {
match self {
Self::ByTime => "by time",
Self::Chronological => "chrono",
}
}
}
fn next_require_threshold(current: f64) -> f64 {
if (current - 1.0).abs() < f64::EPSILON {
0.5
} else if (current - 0.5).abs() < f64::EPSILON {
0.0
} else if current.abs() < f64::EPSILON {
1.0
} else {
1.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RequireRow<'a> {
pub depth: usize,
pub module: &'a str,
pub self_ms: f64,
pub sourced_ms: f64,
pub has_children: bool,
pub is_collapsed: bool,
}
pub fn flatten_require_tree<'a>(
root: &'a RequireNode,
threshold_ms: f64,
sort: RequireTreeSort,
max_rows: usize,
collapsed: &std::collections::HashSet<String>,
) -> Vec<RequireRow<'a>> {
let mut out = Vec::with_capacity(max_rows);
walk(
root,
0,
threshold_ms,
sort,
max_rows,
collapsed,
&mut out,
true,
);
out
}
#[allow(clippy::too_many_arguments)] fn walk<'a>(
node: &'a RequireNode,
depth: usize,
threshold: f64,
sort: RequireTreeSort,
max_rows: usize,
collapsed: &std::collections::HashSet<String>,
out: &mut Vec<RequireRow<'a>>,
is_root: bool,
) {
if out.len() >= max_rows {
return;
}
if !is_root && node.sourced_ms < threshold {
return;
}
let has_children = !node.children.is_empty();
let is_collapsed = has_children && collapsed.contains(node.module.as_str());
out.push(RequireRow {
depth,
module: &node.module,
self_ms: node.self_ms,
sourced_ms: node.sourced_ms,
has_children,
is_collapsed,
});
if is_collapsed {
return;
}
match sort {
RequireTreeSort::ByTime => {
let mut children: Vec<&RequireNode> = node.children.iter().collect();
children.sort_by(|a, b| {
b.sourced_ms
.partial_cmp(&a.sourced_ms)
.unwrap_or(std::cmp::Ordering::Equal)
});
for c in children {
if out.len() >= max_rows {
break;
}
walk(
c,
depth + 1,
threshold,
sort,
max_rows,
collapsed,
out,
false,
);
}
}
RequireTreeSort::Chronological => {
for c in &node.children {
if out.len() >= max_rows {
break;
}
walk(
c,
depth + 1,
threshold,
sort,
max_rows,
collapsed,
out,
false,
);
}
}
}
}
struct ProfileTuiState {
report: ProfileReport,
sort_key: SortKey,
hide_groups: bool,
table_state: TableState,
show_help: bool,
require_tree_threshold_ms: f64,
require_tree_sort: RequireTreeSort,
focus: Focus,
tree_cursor: usize,
tree_collapsed: std::collections::HashSet<String>,
}
impl ProfileTuiState {
fn new(report: ProfileReport) -> Self {
let mut ts = TableState::default();
ts.select(Some(0));
let sort_key = if report.no_instrument {
SortKey::Total
} else {
SortKey::Load
};
Self {
report,
sort_key,
hide_groups: false,
table_state: ts,
show_help: false,
require_tree_threshold_ms: 1.0,
require_tree_sort: RequireTreeSort::ByTime,
focus: Focus::Table,
tree_cursor: 0,
tree_collapsed: std::collections::HashSet::new(),
}
}
fn visible_indices(&self) -> Vec<usize> {
let mut idxs: Vec<usize> = self
.report
.plugins
.iter()
.enumerate()
.filter(|(_, p)| !self.hide_groups || !is_group_name(&p.name))
.map(|(i, _)| i)
.collect();
let ps = &self.report.plugins;
match self.sort_key {
SortKey::Load => idxs.sort_by(|&a, &b| cmp_f64(ps[b].load_ms, ps[a].load_ms)),
SortKey::Init => idxs.sort_by(|&a, &b| cmp_f64(ps[b].init_ms, ps[a].init_ms)),
SortKey::Trig => idxs.sort_by(|&a, &b| cmp_f64(ps[b].trig_ms, ps[a].trig_ms)),
SortKey::Total => {
idxs.sort_by(|&a, &b| cmp_f64(ps[b].total_self_ms, ps[a].total_self_ms))
}
SortKey::Files => idxs.sort_by(|&a, &b| ps[b].file_count.cmp(&ps[a].file_count)),
SortKey::Name => idxs.sort_by(|&a, &b| ps[a].name.cmp(&ps[b].name)),
}
idxs
}
fn selected_plugin_index(&self) -> Option<usize> {
let vis = self.visible_indices();
self.table_state
.selected()
.and_then(|i| vis.get(i).copied())
}
fn move_by(&mut self, delta: isize) {
let len = self.visible_indices().len();
if len == 0 {
return;
}
let cur = self.table_state.selected().unwrap_or(0) as isize;
let new = (cur + delta).rem_euclid(len as isize) as usize;
self.table_state.select(Some(new));
}
fn go_top(&mut self) {
if !self.visible_indices().is_empty() {
self.table_state.select(Some(0));
}
}
fn go_bottom(&mut self) {
let len = self.visible_indices().len();
if len > 0 {
self.table_state.select(Some(len - 1));
}
}
fn current_require_rows(&self) -> Option<Vec<RequireRow<'_>>> {
let idx = self.selected_plugin_index()?;
let p = &self.report.plugins[idx];
if p.name != GROUP_USER {
return None;
}
let tree = p.require_trace.as_ref()?;
Some(flatten_require_tree(
tree,
self.require_tree_threshold_ms,
self.require_tree_sort,
512,
&self.tree_collapsed,
))
}
fn tree_cursor_move(&mut self, delta: isize) {
let Some(rows) = self.current_require_rows() else {
return;
};
let len = rows.len();
if len == 0 {
return;
}
let cur = self.tree_cursor.min(len - 1) as isize;
let new = (cur + delta).rem_euclid(len as isize) as usize;
self.tree_cursor = new;
}
fn tree_go_bottom(&mut self) {
let Some(rows) = self.current_require_rows() else {
return;
};
if !rows.is_empty() {
self.tree_cursor = rows.len() - 1;
}
}
fn tree_toggle_at_cursor(&mut self, collapse: bool) {
let Some(rows) = self.current_require_rows() else {
return;
};
if rows.is_empty() {
return;
}
let cur = self.tree_cursor.min(rows.len() - 1);
let row = &rows[cur];
if !row.has_children {
return;
}
let name = row.module.to_string();
if collapse {
self.tree_collapsed.insert(name);
} else {
self.tree_collapsed.remove(&name);
}
}
}
fn cmp_f64(a: f64, b: f64) -> std::cmp::Ordering {
a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal)
}
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let mut stdout = std::io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen);
let _ = execute!(stdout, crossterm::cursor::Show);
}
}
pub fn run(report: ProfileReport) -> Result<()> {
enable_raw_mode()?;
let _guard = TerminalGuard;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = ratatui::Terminal::new(backend)?;
let mut state = ProfileTuiState::new(report);
let result = run_loop(&mut terminal, &mut state);
terminal.show_cursor()?;
result
}
fn run_loop(
terminal: &mut ratatui::Terminal<CrosstermBackend<std::io::Stdout>>,
state: &mut ProfileTuiState,
) -> Result<()> {
loop {
terminal.draw(|f| draw(f, state))?; if event::poll(std::time::Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
{
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Tab => state.focus = state.focus.toggle(),
KeyCode::Char('j') | KeyCode::Down => match state.focus {
Focus::Table => state.move_by(1),
Focus::Detail => state.tree_cursor_move(1),
},
KeyCode::Char('k') | KeyCode::Up => match state.focus {
Focus::Table => state.move_by(-1),
Focus::Detail => state.tree_cursor_move(-1),
},
KeyCode::Char('g') | KeyCode::Home => match state.focus {
Focus::Table => state.go_top(),
Focus::Detail => state.tree_cursor = 0,
},
KeyCode::Char('G') | KeyCode::End => match state.focus {
Focus::Table => state.go_bottom(),
Focus::Detail => state.tree_go_bottom(),
},
KeyCode::Char('l') | KeyCode::Right if state.focus == Focus::Detail => {
state.tree_toggle_at_cursor(false);
}
KeyCode::Char('h') | KeyCode::Left if state.focus == Focus::Detail => {
state.tree_toggle_at_cursor(true);
}
KeyCode::Char('s') => state.sort_key = state.sort_key.next(),
KeyCode::Char('h') => state.hide_groups = !state.hide_groups,
KeyCode::Char('?') => state.show_help = !state.show_help,
KeyCode::Char('f') => {
state.require_tree_threshold_ms =
next_require_threshold(state.require_tree_threshold_ms);
}
KeyCode::Char('c') => {
state.require_tree_sort = state.require_tree_sort.toggle();
}
_ => {}
}
}
}
}
fn draw(f: &mut Frame, state: &mut ProfileTuiState) {
let area = f.area();
let has_timeline = state.report.phase_timeline.is_some();
let timeline_h = if has_timeline { 9 } else { 0 };
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(timeline_h), Constraint::Min(6), Constraint::Length(14), Constraint::Length(3), ])
.split(area);
draw_banner(f, chunks[0], state);
if has_timeline {
draw_phase_timeline(f, chunks[1], state);
}
draw_plugin_table(f, chunks[2], state);
draw_detail(f, chunks[3], state);
draw_footer(f, chunks[4], state);
if state.show_help {
draw_help_overlay(f, area);
}
}
fn draw_banner(f: &mut Frame, area: Rect, state: &ProfileTuiState) {
let total = state.report.total_startup_ms;
let rating_color = startup_color(total);
let rating = startup_rating(total);
let nvim = state.report.nvim_version.as_deref().unwrap_or("nvim");
let mut spans = vec![
Span::styled(
" \u{26a1} rvpm profile ",
Style::default()
.fg(Color::Black)
.bg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("{:>7.1} ms", total),
Style::default()
.fg(rating_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(rating, Style::default().fg(rating_color)),
Span::raw(" "),
Span::styled(
format!(
"avg of {} run{}",
state.report.runs,
if state.report.runs == 1 { "" } else { "s" }
),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled(
truncate(nvim, area.width.saturating_sub(62) as usize),
Style::default().fg(Color::DarkGray),
),
];
if state.report.no_instrument {
spans.push(Span::raw(" "));
spans.push(Span::styled(
" raw ",
Style::default()
.fg(Color::Black)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
));
} else if state.report.no_merge {
spans.push(Span::raw(" "));
spans.push(Span::styled(
" no-merge ",
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Magenta));
let widget = Paragraph::new(Line::from(spans))
.alignment(Alignment::Left)
.block(block);
f.render_widget(widget, area);
}
fn draw_phase_timeline(f: &mut Frame, area: Rect, state: &ProfileTuiState) {
let Some(phases) = state.report.phase_timeline.as_ref() else {
return;
};
let total = phases.iter().map(|p| p.duration_ms).sum::<f64>().max(1e-6);
let inner_w = area.width.saturating_sub(4) as usize;
let bar_w = inner_w.saturating_sub(30);
let lines: Vec<Line> = phases.iter().map(|p| phase_row(p, total, bar_w)).collect();
let title = Line::from(vec![
Span::styled(
" phase timeline ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled("(avg per run, ms)", Style::default().fg(Color::DarkGray)),
]);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray));
let widget = Paragraph::new(lines).block(block);
f.render_widget(widget, area);
}
fn phase_row<'a>(p: &'a PhaseTime, total: f64, bar_w: usize) -> Line<'a> {
let ratio = (p.duration_ms / total).clamp(0.0, 1.0);
let filled = (ratio * bar_w as f64).round() as usize;
let color = bar_color(p.duration_ms, total.max(1.0));
let bar: String = std::iter::repeat_n('\u{2588}', filled)
.chain(std::iter::repeat_n(
'\u{2591}',
bar_w.saturating_sub(filled),
))
.collect();
let label = phase_label(&p.name);
Line::from(vec![
Span::styled(format!(" {:<16}", label), Style::default().fg(Color::Gray)),
Span::styled(
format!("{:>7.2} ms ", p.duration_ms),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(bar, Style::default().fg(color)),
])
}
fn phase_label(name: &str) -> String {
match name {
"phase-3" => "P3 before".into(),
"phase-4" => "P4 init".into(),
"phase-5" => "P5 rtp".into(),
"phase-6" => "P6 eager".into(),
"phase-7" => "P7 lazy reg".into(),
"phase-8" => "P8 colorscheme".into(),
"phase-9" => "P9 after".into(),
_ => name.to_string(),
}
}
fn draw_plugin_table(f: &mut Frame, area: Rect, state: &mut ProfileTuiState) {
let vis = state.visible_indices();
let max_for_sort = vis
.iter()
.map(|&i| selector_value(state, i))
.fold(0.0_f64, f64::max)
.max(1e-6);
let inner_w = area.width.saturating_sub(2) as usize;
let bar_w = inner_w.saturating_sub(70).max(6);
let header_row = Row::new(vec![
Cell::from(Span::styled(
" # ",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)),
Cell::from(Span::styled(
"kind ",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)),
Cell::from(Span::styled(
"plugin",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)),
Cell::from(Span::styled(
"init",
sort_header_style(state.sort_key == SortKey::Init),
)),
Cell::from(Span::styled(
"load",
sort_header_style(state.sort_key == SortKey::Load),
)),
Cell::from(Span::styled(
"trig",
sort_header_style(state.sort_key == SortKey::Trig),
)),
Cell::from(Span::styled(
"total",
sort_header_style(state.sort_key == SortKey::Total),
)),
Cell::from(Span::styled(
"distribution",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)),
])
.height(1);
let rows: Vec<Row> = vis
.iter()
.enumerate()
.map(|(visual_i, &plugin_i)| {
let p = &state.report.plugins[plugin_i];
let is_group = is_group_name(&p.name);
let name_color = if is_group {
Color::DarkGray
} else {
Color::White
};
let (badge, badge_color) = plugin_badge(p);
let val = selector_value(state, plugin_i);
let bar_color = bar_color(val, max_for_sort);
let filled = ((val / max_for_sort) * bar_w as f64).round() as usize;
let bar: String = std::iter::repeat_n('\u{2588}', filled)
.chain(std::iter::repeat_n(
'\u{2591}',
bar_w.saturating_sub(filled),
))
.collect();
Row::new(vec![
Cell::from(Span::styled(
format!("{:>3}", visual_i + 1),
Style::default().fg(Color::DarkGray),
)),
Cell::from(Span::styled(
format!(" {} ", badge),
Style::default()
.fg(badge_color)
.add_modifier(Modifier::BOLD),
)),
Cell::from(Span::styled(
truncate(&p.name, 24),
Style::default().fg(name_color),
)),
Cell::from(format_ms_cell(p.init_ms, p.is_managed && p.init_ms > 0.0)),
Cell::from(format_ms_cell(p.load_ms, p.is_managed && !p.lazy)),
Cell::from(format_ms_cell(p.trig_ms, p.is_managed && p.lazy)),
Cell::from(Span::styled(
format!("{:>6.2}", p.total_self_ms),
Style::default().fg(Color::Gray),
)),
Cell::from(Span::styled(bar, Style::default().fg(bar_color))),
])
})
.collect();
let title = Line::from(vec![
Span::styled(
" plugins ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(
"({} shown, sort: {}{}) ",
vis.len(),
state.sort_key.label(),
if state.hide_groups {
", groups hidden"
} else {
""
}
),
Style::default().fg(Color::DarkGray),
),
]);
let table = Table::new(
rows,
[
Constraint::Length(4),
Constraint::Length(6),
Constraint::Length(26),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(7),
Constraint::Min(6),
],
)
.header(header_row)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(if state.focus == Focus::Table {
Color::Magenta
} else {
Color::DarkGray
})),
)
.row_highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{25b6} ");
f.render_stateful_widget(table, area, &mut state.table_state);
if vis.len() > area.height.saturating_sub(3) as usize {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None);
let mut sb_state =
ScrollbarState::new(vis.len()).position(state.table_state.selected().unwrap_or(0));
f.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut sb_state,
);
}
}
fn selector_value(state: &ProfileTuiState, plugin_i: usize) -> f64 {
let p = &state.report.plugins[plugin_i];
match state.sort_key {
SortKey::Load => p.load_ms,
SortKey::Init => p.init_ms,
SortKey::Trig => p.trig_ms,
SortKey::Total => p.total_self_ms,
SortKey::Files => p.file_count as f64,
SortKey::Name => p.total_self_ms, }
}
fn format_ms_cell(value: f64, applicable: bool) -> Span<'static> {
if !applicable || value <= 0.0 {
Span::styled(" - ", Style::default().fg(Color::DarkGray))
} else {
Span::styled(
format!("{:>6.2} ", value),
Style::default().fg(Color::Gray),
)
}
}
fn sort_header_style(active: bool) -> Style {
if active {
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
}
}
fn plugin_badge(p: &crate::profile::PluginStats) -> (&'static str, Color) {
if !p.is_managed {
("grp", Color::DarkGray)
} else if p.lazy {
("lazy", Color::Cyan)
} else {
("eagr", Color::Green)
}
}
fn draw_require_tree_detail(
f: &mut Frame,
area: Rect,
state: &ProfileTuiState,
plugin: &crate::profile::PluginStats,
tree: &RequireNode,
) {
let inner_h = area.height.saturating_sub(3) as usize;
let body_h = inner_h.saturating_sub(1).max(1);
let rows = flatten_require_tree(
tree,
state.require_tree_threshold_ms,
state.require_tree_sort,
512,
&state.tree_collapsed,
);
let max_sourced = rows.first().map(|r| r.sourced_ms).unwrap_or(0.0).max(1e-6);
let bar_w = (area.width.saturating_sub(60) as usize).max(4);
let cursor = state.tree_cursor.min(rows.len().saturating_sub(1));
let scroll = if rows.len() <= body_h {
0
} else {
let half = body_h / 2;
cursor.saturating_sub(half).min(rows.len() - body_h)
};
let visible_end = (scroll + body_h).min(rows.len());
let focused = state.focus == Focus::Detail;
let mut lines: Vec<Line> = Vec::with_capacity(body_h + 1);
let total_nodes = count_require_nodes(tree);
lines.push(Line::from(vec![
Span::styled(" require tree ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!(
"({}-{} of {} · threshold {:.1} ms · sort {})",
scroll + 1,
visible_end,
total_nodes,
state.require_tree_threshold_ms,
state.require_tree_sort.label(),
),
Style::default().fg(Color::DarkGray),
),
]));
for (idx, row) in rows[scroll..visible_end].iter().enumerate() {
let row_idx = scroll + idx;
let is_cursor = focused && row_idx == cursor;
let icon = if row.has_children {
if row.is_collapsed {
'\u{25b6}'
} else {
'\u{25bc}'
} } else {
require_tree_icon(row.depth) };
let indent: String = " ".repeat(row.depth);
let filled = ((row.sourced_ms / max_sourced) * bar_w as f64).round() as usize;
let bar: String = std::iter::repeat_n('\u{2588}', filled)
.chain(std::iter::repeat_n(
'\u{2591}',
bar_w.saturating_sub(filled),
))
.collect();
let color = bar_color(row.sourced_ms, max_sourced);
let name_width = 34usize.saturating_sub(row.depth * 2).max(8);
let cursor_marker = if is_cursor { '\u{25b6}' } else { ' ' }; let name_style = if is_cursor {
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default().fg(Color::Gray)
};
lines.push(Line::from(vec![
Span::styled(
format!("{}{}{} ", cursor_marker, indent, icon),
Style::default().fg(if is_cursor {
Color::Magenta
} else {
Color::DarkGray
}),
),
Span::styled(pad_truncate(row.module, name_width), name_style),
Span::styled(
format!(" {:>6.2} ms ", row.sourced_ms),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(bar, Style::default().fg(color)),
]));
}
let summary = format!(
" tree self {:.2} ms · sourced {:.2} ms · config self {:.2} ms",
tree.self_ms, tree.sourced_ms, plugin.total_self_ms
);
let title = Line::from(vec![
Span::styled(
format!(" {} ", plugin.name),
Style::default()
.fg(Color::Black)
.bg(bar_color(
plugin.total_self_ms,
state.report.total_startup_ms.max(1.0),
))
.add_modifier(Modifier::BOLD),
),
Span::styled(summary, Style::default().fg(Color::Gray)),
]);
let border_color = if focused {
Color::Magenta
} else {
Color::DarkGray
};
let widget = Paragraph::new(lines).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color)),
);
f.render_widget(widget, area);
}
fn require_tree_icon(depth: usize) -> char {
match depth % 3 {
0 => '\u{25cf}', 1 => '\u{25cb}', _ => '\u{25c9}', }
}
fn count_require_nodes(node: &RequireNode) -> usize {
1 + node.children.iter().map(count_require_nodes).sum::<usize>()
}
fn draw_detail(f: &mut Frame, area: Rect, state: &ProfileTuiState) {
let Some(idx) = state.selected_plugin_index() else {
return;
};
let p = &state.report.plugins[idx];
if p.name == GROUP_USER
&& let Some(tree) = p.require_trace.as_ref()
{
draw_require_tree_detail(f, area, state, p, tree);
return;
}
let max_file_ms = p
.top_files
.iter()
.map(|f| f.self_ms)
.fold(0.0_f64, f64::max)
.max(1e-6);
let bar_w = (area.width.saturating_sub(60) as usize).max(4);
let mut lines: Vec<Line> = Vec::new();
let header = if p.lazy {
" trigger registration cost "
} else if !p.is_managed {
" sourced files "
} else {
" phase 6 files (plugin/, ftdetect/, after/plugin/) "
};
lines.push(Line::from(vec![
Span::styled(header, Style::default().fg(Color::DarkGray)),
Span::styled(
format!("({} files)", p.file_count),
Style::default().fg(Color::DarkGray),
),
]));
if p.top_files.is_empty() {
if p.lazy {
lines.push(Line::from(Span::styled(
" lazy plugin — loaded on trigger, no sourcing during startup",
Style::default().fg(Color::DarkGray),
)));
} else {
lines.push(Line::from(Span::styled(
" (no sourced files recorded)",
Style::default().fg(Color::DarkGray),
)));
}
}
for (i, file) in p.top_files.iter().take(6).enumerate() {
let filled = ((file.self_ms / max_file_ms) * bar_w as f64).round() as usize;
let bar: String = std::iter::repeat_n('\u{2588}', filled)
.chain(std::iter::repeat_n(
'\u{2591}',
bar_w.saturating_sub(filled),
))
.collect();
let color = bar_color(file.self_ms, max_file_ms);
lines.push(Line::from(vec![
Span::styled(
format!(" {:>2}. ", i + 1),
Style::default().fg(Color::DarkGray),
),
Span::styled(
pad_truncate(&file.relative_path, 34),
Style::default().fg(Color::Gray),
),
Span::styled(
format!(" {:>6.2} ms ", file.self_ms),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(bar, Style::default().fg(color)),
]));
}
let summary = format!(
" init {:.2} load {:.2} trig {:.2} / total {:.2} ms",
p.init_ms, p.load_ms, p.trig_ms, p.total_self_ms
);
let title = Line::from(vec![
Span::styled(
format!(" {} ", p.name),
Style::default()
.fg(Color::Black)
.bg(bar_color(
p.total_self_ms,
state.report.total_startup_ms.max(1.0),
))
.add_modifier(Modifier::BOLD),
),
Span::styled(summary, Style::default().fg(Color::Gray)),
]);
let border_color = if state.focus == Focus::Detail {
Color::Magenta
} else {
Color::DarkGray
};
let widget = Paragraph::new(lines).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color)),
);
f.render_widget(widget, area);
}
fn draw_footer(f: &mut Frame, area: Rect, _state: &ProfileTuiState) {
let mut spans: Vec<Span> = Vec::new();
for (k, d) in [
("j/k", "move"),
("g/G", "top/bot"),
("Tab", "focus"),
("h/l", "collapse"),
("s/c/f", "sort/thresh"),
("?", "help"),
("q", "quit"),
] {
spans.extend(key_hint(k, d));
}
let widget = Paragraph::new(Line::from(spans))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(widget, area);
}
fn key_hint(key: &'static str, desc: &'static str) -> Vec<Span<'static>> {
vec![
Span::styled(
format!(" {} ", key),
Style::default()
.fg(Color::Black)
.bg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {} ", desc), Style::default().fg(Color::Gray)),
]
}
fn draw_help_overlay(f: &mut Frame, area: Rect) {
let w = 76.min(area.width.saturating_sub(4));
let h = 24.min(area.height.saturating_sub(4));
let x = (area.width.saturating_sub(w)) / 2;
let y = (area.height.saturating_sub(h)) / 2;
let rect = Rect::new(x, y, w, h);
let lines = vec![
Line::from(Span::styled(
" rvpm profile — keys",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(" j / k / ↑ / ↓ move selection"),
Line::from(" g / G jump to top / bottom"),
Line::from(" s cycle sort (load → init → trig → …)"),
Line::from(" Tab toggle focus (plugin table ↔ require tree)"),
Line::from(""),
Line::from(Span::styled(
" plugin table (default focus)",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(" h toggle [merged]/[runtime] group rows"),
Line::from(""),
Line::from(Span::styled(
" require tree ([user config], focus = Detail)",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(" h / ← collapse subtree at cursor"),
Line::from(" l / → expand subtree at cursor"),
Line::from(" f threshold cycle (1.0 → 0.5 → 0.0 ms)"),
Line::from(" c sort toggle (by time ↔ chronological)"),
Line::from(""),
Line::from(" ? toggle this help"),
Line::from(" q / Esc quit"),
Line::from(""),
Line::from(Span::styled(
" columns",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
" init = phase 4 (per-plugin init.lua, pre-rtp)",
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
" load = phase 6 (eager plugin/ftdetect/after source)",
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
" trig = phase 7 (lazy trigger registration)",
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
" total = sum of all sourced files' self ms",
Style::default().fg(Color::DarkGray),
)),
];
let widget = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Magenta)),
);
f.render_widget(ratatui::widgets::Clear, rect);
f.render_widget(widget, rect);
}
fn bar_color(ms: f64, max_ms: f64) -> Color {
let ratio = (ms / max_ms).clamp(0.0, 1.0);
if ratio < 0.25 {
Color::Green
} else if ratio < 0.5 {
Color::Cyan
} else if ratio < 0.75 {
Color::Yellow
} else {
Color::Red
}
}
fn startup_rating(total_ms: f64) -> &'static str {
if total_ms < 100.0 {
"blazing \u{1f525}"
} else if total_ms < 200.0 {
"fast"
} else if total_ms < 400.0 {
"ok"
} else {
"slow \u{26a0}"
}
}
fn startup_color(total_ms: f64) -> Color {
if total_ms < 100.0 {
Color::Green
} else if total_ms < 200.0 {
Color::Cyan
} else if total_ms < 400.0 {
Color::Yellow
} else {
Color::Red
}
}
fn truncate(s: &str, max: usize) -> String {
if UnicodeWidthStr::width(s) <= max {
return s.to_string();
}
let budget = max.saturating_sub(1);
let mut out = String::new();
let mut used = 0usize;
for c in s.chars() {
let w = UnicodeWidthChar::width(c).unwrap_or(0);
if used + w > budget {
break;
}
out.push(c);
used += w;
}
out.push('\u{2026}');
out
}
fn pad_truncate(s: &str, width: usize) -> String {
let truncated = truncate(s, width);
let w = UnicodeWidthStr::width(truncated.as_str());
if w < width {
let mut out = truncated;
out.extend(std::iter::repeat_n(' ', width - w));
out
} else {
truncated
}
}
pub fn print_plain(report: &ProfileReport, top: Option<usize>) {
println!("# rvpm profile");
let mode = if report.no_instrument {
"raw --startuptime"
} else if report.no_merge {
"instrumented + no-merge"
} else {
"instrumented"
};
println!(
"total_startup_ms = {:.2} runs = {} mode = {} nvim = {}",
report.total_startup_ms,
report.runs,
mode,
report.nvim_version.as_deref().unwrap_or("(unknown)")
);
if let Some(phases) = &report.phase_timeline {
println!();
println!("## phase timeline");
for p in phases {
println!(" {:<14} {:>8.2} ms", phase_label(&p.name), p.duration_ms);
}
}
println!();
println!(
" {:>4} {:>4} {:<26} {:>8} {:>8} {:>8} {:>8} {:>6}",
"#", "kind", "plugin", "init ms", "load ms", "trig ms", "total", "files"
);
println!(" {}", "-".repeat(92));
for (i, p) in report
.plugins
.iter()
.take(top.unwrap_or(usize::MAX))
.enumerate()
{
let kind = if !p.is_managed {
"grp"
} else if p.lazy {
"lazy"
} else {
"eagr"
};
let show_init = if p.is_managed && p.init_ms > 0.0 {
format!("{:.2}", p.init_ms)
} else {
"-".into()
};
let show_load = if p.is_managed && !p.lazy && p.load_ms > 0.0 {
format!("{:.2}", p.load_ms)
} else {
"-".into()
};
let show_trig = if p.is_managed && p.lazy && p.trig_ms > 0.0 {
format!("{:.2}", p.trig_ms)
} else {
"-".into()
};
println!(
" {:>4} {:>4} {:<26} {:>8} {:>8} {:>8} {:>8.2} {:>6}",
i + 1,
kind,
truncate(&p.name, 26),
show_init,
show_load,
show_trig,
p.total_self_ms,
p.file_count,
);
}
if let Some(user_cfg) = report
.plugins
.iter()
.find(|p| p.name == crate::profile::GROUP_USER)
&& let Some(tree) = user_cfg.require_trace.as_ref()
{
println!();
let nodes = count_require_nodes(tree);
println!(
"## require tree ({} nodes, sourced {:.2} ms, self {:.2} ms)",
nodes, tree.sourced_ms, tree.self_ms
);
print_require_tree_plain(tree, 0);
}
}
fn print_require_tree_plain(node: &RequireNode, depth: usize) {
let indent: String = " ".repeat(depth);
println!(
" {}{:<40} sourced {:>7.2} ms · self {:>7.2} ms",
indent, node.module, node.sourced_ms, node.self_ms,
);
let mut children: Vec<&RequireNode> = node.children.iter().collect();
children.sort_by(|a, b| {
b.sourced_ms
.partial_cmp(&a.sourced_ms)
.unwrap_or(std::cmp::Ordering::Equal)
});
for c in children {
print_require_tree_plain(c, depth + 1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::RequireNode;
use unicode_width::UnicodeWidthStr;
fn sample_tree() -> RequireNode {
RequireNode {
module: "init.lua".into(),
self_ms: 0.6,
sourced_ms: 10.0,
children: vec![
RequireNode {
module: "A".into(),
self_ms: 1.0,
sourced_ms: 7.0,
children: vec![
RequireNode {
module: "A1".into(),
self_ms: 5.0,
sourced_ms: 5.0,
children: vec![],
},
RequireNode {
module: "A2".into(),
self_ms: 1.0,
sourced_ms: 1.0,
children: vec![],
},
],
},
RequireNode {
module: "B".into(),
self_ms: 0.4,
sourced_ms: 0.4,
children: vec![],
},
RequireNode {
module: "C".into(),
self_ms: 2.0,
sourced_ms: 2.0,
children: vec![],
},
],
}
}
#[test]
fn flatten_require_tree_includes_root_with_depth_0() {
let tree = sample_tree();
let rows = flatten_require_tree(
&tree,
0.0,
RequireTreeSort::ByTime,
99,
&std::collections::HashSet::new(),
);
assert_eq!(rows[0].depth, 0);
assert_eq!(rows[0].module, "init.lua");
}
#[test]
fn flatten_require_tree_sorts_siblings_by_sourced_ms_desc() {
let tree = sample_tree();
let rows = flatten_require_tree(
&tree,
0.0,
RequireTreeSort::ByTime,
99,
&std::collections::HashSet::new(),
);
let modules: Vec<&str> = rows.iter().map(|r| r.module).collect();
assert_eq!(modules, vec!["init.lua", "A", "A1", "A2", "C", "B"]);
}
#[test]
fn flatten_require_tree_keeps_insertion_order_under_chronological() {
let tree = sample_tree();
let rows = flatten_require_tree(
&tree,
0.0,
RequireTreeSort::Chronological,
99,
&std::collections::HashSet::new(),
);
let modules: Vec<&str> = rows.iter().map(|r| r.module).collect();
assert_eq!(modules, vec!["init.lua", "A", "A1", "A2", "B", "C"]);
}
#[test]
fn flatten_require_tree_skips_subtrees_below_threshold() {
let tree = sample_tree();
let rows = flatten_require_tree(
&tree,
1.0,
RequireTreeSort::ByTime,
99,
&std::collections::HashSet::new(),
);
let modules: Vec<&str> = rows.iter().map(|r| r.module).collect();
assert!(!modules.contains(&"B"));
assert!(modules.contains(&"A2")); }
#[test]
fn flatten_require_tree_never_cuts_root_even_below_threshold() {
let tiny = RequireNode {
module: "init.lua".into(),
self_ms: 0.1,
sourced_ms: 0.1,
children: vec![],
};
let rows = flatten_require_tree(
&tiny,
1.0,
RequireTreeSort::ByTime,
99,
&std::collections::HashSet::new(),
);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].module, "init.lua");
}
#[test]
fn flatten_require_tree_respects_max_rows() {
let tree = sample_tree();
let rows = flatten_require_tree(
&tree,
0.0,
RequireTreeSort::ByTime,
3,
&std::collections::HashSet::new(),
);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].module, "init.lua");
assert_eq!(rows[1].module, "A");
assert_eq!(rows[2].module, "A1");
}
#[test]
fn flatten_require_tree_carries_sourced_and_self_ms_and_depth() {
let tree = sample_tree();
let rows = flatten_require_tree(
&tree,
0.0,
RequireTreeSort::ByTime,
99,
&std::collections::HashSet::new(),
);
let a = rows.iter().find(|r| r.module == "A").unwrap();
assert_eq!(a.depth, 1);
assert!((a.sourced_ms - 7.0).abs() < 1e-9);
assert!((a.self_ms - 1.0).abs() < 1e-9);
let a1 = rows.iter().find(|r| r.module == "A1").unwrap();
assert_eq!(a1.depth, 2);
}
#[test]
fn flatten_require_tree_marks_has_children_for_renderer() {
let tree = sample_tree();
let rows = flatten_require_tree(
&tree,
0.0,
RequireTreeSort::ByTime,
99,
&std::collections::HashSet::new(),
);
let root = &rows[0];
assert!(root.has_children, "init.lua は 3 children あり");
assert!(!root.is_collapsed);
let b = rows.iter().find(|r| r.module == "B").unwrap();
assert!(!b.has_children, "B は leaf");
let a1 = rows.iter().find(|r| r.module == "A1").unwrap();
assert!(!a1.has_children, "A1 も leaf");
}
#[test]
fn flatten_require_tree_hides_children_of_collapsed_nodes() {
let tree = sample_tree();
let mut collapsed = std::collections::HashSet::new();
collapsed.insert("A".to_string());
let rows = flatten_require_tree(&tree, 0.0, RequireTreeSort::ByTime, 99, &collapsed);
let modules: Vec<&str> = rows.iter().map(|r| r.module).collect();
assert!(modules.contains(&"A"));
assert!(!modules.contains(&"A1"));
assert!(!modules.contains(&"A2"));
let a = rows.iter().find(|r| r.module == "A").unwrap();
assert!(a.has_children);
assert!(a.is_collapsed);
}
#[test]
fn focus_toggles_between_table_and_detail() {
assert_eq!(Focus::Table.toggle(), Focus::Detail);
assert_eq!(Focus::Detail.toggle(), Focus::Table);
}
#[test]
fn require_tree_threshold_cycles_through_three_steps() {
assert_eq!(next_require_threshold(1.0), 0.5);
assert_eq!(next_require_threshold(0.5), 0.0);
assert_eq!(next_require_threshold(0.0), 1.0);
assert_eq!(next_require_threshold(0.123), 1.0);
}
#[test]
fn require_tree_sort_toggles_between_two_modes() {
assert_eq!(
RequireTreeSort::ByTime.toggle(),
RequireTreeSort::Chronological
);
assert_eq!(
RequireTreeSort::Chronological.toggle(),
RequireTreeSort::ByTime
);
}
#[test]
fn pad_truncate_pads_short_strings_to_exact_display_width() {
let out = pad_truncate("plugin/denops.vim", 34);
assert_eq!(UnicodeWidthStr::width(out.as_str()), 34);
assert!(out.starts_with("plugin/denops.vim"));
assert!(out.ends_with(' '));
}
#[test]
fn pad_truncate_truncates_long_strings_to_exact_display_width() {
let long = "autoload/denops/_internal/very/deep/nested/file.vim";
let out = pad_truncate(long, 34);
assert_eq!(UnicodeWidthStr::width(out.as_str()), 34);
assert!(out.ends_with('\u{2026}'));
}
#[test]
fn pad_truncate_preserves_exact_width_input() {
let exact: String = "x".repeat(34);
let out = pad_truncate(&exact, 34);
assert_eq!(UnicodeWidthStr::width(out.as_str()), 34);
assert_eq!(out, exact);
}
#[test]
fn pad_truncate_measures_full_width_chars_by_display_width() {
let jp = "日本語プラグイン.vim"; let out = pad_truncate(jp, 34);
assert_eq!(UnicodeWidthStr::width(out.as_str()), 34);
assert!(out.starts_with(jp));
}
#[test]
fn truncate_respects_display_width_when_clipping_full_width_chars() {
let out = truncate("日本語", 5);
assert_eq!(UnicodeWidthStr::width(out.as_str()), 5);
assert!(out.ends_with('\u{2026}'));
}
}