use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::App;
use crate::ui::theme;
pub const DEFAULT_INFO_BOX_HEIGHT: u16 = 8;
const HOVER_HELP_DEBOUNCE_MS: u128 = 350;
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
if area.height == 0 || area.width == 0 {
return;
}
app.rects.hover_help_strip = Some(area);
app.rects.hover_help_try_it.clear();
app.rects.hover_help_docs = None;
let t = theme::cur();
let body_bg = t.bg_darker;
let title_bg = t.bg2;
frame.render_widget(Paragraph::new("").style(Style::default().bg(body_bg)), area);
let copy = debounced_help_copy(app);
let w = area.width as usize;
let sep_style = Style::default()
.fg(t.comment)
.bg(body_bg)
.add_modifier(Modifier::DIM);
frame.render_widget(
Paragraph::new(Line::from(Span::styled("─".repeat(w), sep_style))),
Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
},
);
if area.height <= 1 {
return;
}
let kebab_glyph = "⋮";
let kebab_cells = 2u16;
let title_avail = area.width.saturating_sub(kebab_cells);
let prefix_cells = 1u16;
let title_body_avail = title_avail.saturating_sub(prefix_cells);
let title_text: String = copy
.title
.chars()
.take(title_body_avail.saturating_sub(1) as usize)
.collect();
let title_body = pad_line(&title_text, title_body_avail as usize);
let mut title_spans = vec![
Span::styled(" ", Style::default().bg(title_bg)),
Span::styled(
title_body,
Style::default()
.fg(t.fg)
.bg(title_bg)
.add_modifier(Modifier::BOLD),
),
];
if area.width >= 3 {
title_spans.push(Span::styled(
kebab_glyph,
Style::default().fg(t.comment).bg(title_bg),
));
title_spans.push(Span::styled(" ", Style::default().bg(title_bg)));
app.rects.hover_help_kebab = Some(Rect {
x: area.x + area.width - kebab_cells,
y: area.y + 1,
width: 1,
height: 1,
});
} else {
app.rects.hover_help_kebab = None;
}
frame.render_widget(
Paragraph::new(Line::from(title_spans)),
Rect {
x: area.x,
y: area.y + 1,
width: area.width,
height: 1,
},
);
if area.height <= 2 {
return;
}
let content_w = area.width.saturating_sub(2) as usize;
let mut lines: Vec<Line<'static>> = Vec::new();
let mut line_actions: Vec<Option<String>> = Vec::new();
lines.push(spacer(body_bg));
line_actions.push(None);
for line in wrap_words(©.body, content_w) {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().bg(body_bg)),
Span::styled(line, Style::default().fg(t.fg).bg(body_bg)),
]));
line_actions.push(None);
}
if let Some(aside) = ©.aside {
for line in wrap_words(aside, content_w) {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().bg(body_bg)),
Span::styled(
line,
Style::default()
.fg(t.comment)
.bg(body_bg)
.add_modifier(Modifier::ITALIC),
),
]));
line_actions.push(None);
}
}
let max_body_rows = area.height.saturating_sub(3) as usize;
let rows_left = max_body_rows.saturating_sub(lines.len());
if rows_left > 0 && !copy.shortcuts.is_empty() {
lines.push(spacer(body_bg));
line_actions.push(None);
for hint in copy.shortcuts.iter().take(rows_left.saturating_sub(1)) {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().bg(body_bg)),
Span::styled(
format!("[{}]", hint.chord),
Style::default()
.fg(t.cyan)
.bg(body_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", hint.label),
Style::default().fg(t.fg).bg(body_bg),
),
]));
line_actions.push(None);
}
}
let rows_left = max_body_rows.saturating_sub(lines.len());
if rows_left > 0 && !copy.try_it.is_empty() {
lines.push(spacer(body_bg));
line_actions.push(None);
for link in copy.try_it.iter().take(rows_left.saturating_sub(1)) {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().bg(body_bg)),
Span::styled(
format!("→ {}", link.label),
Style::default()
.fg(t.green)
.bg(body_bg)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
),
]));
line_actions.push(Some(link.command_id.clone()));
}
}
let rows_left = max_body_rows.saturating_sub(lines.len());
let docs_line_idx = if rows_left > 0 {
copy.docs.as_ref().map(|_| {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().bg(body_bg)),
Span::styled(
"→ Manual",
Style::default()
.fg(t.cyan)
.bg(body_bg)
.add_modifier(Modifier::UNDERLINED),
),
]));
line_actions.push(None);
lines.len() - 1
})
} else {
None
};
let body_rect = Rect {
x: area.x,
y: area.y + 2,
width: area.width,
height: area.height.saturating_sub(3),
};
let cap = body_rect.height as usize;
let total_lines = lines.len();
let overflow = total_lines > cap;
let max_scroll = total_lines.saturating_sub(cap) as u16;
let scroll = app.hover_help_scroll.min(max_scroll);
app.hover_help_scroll = scroll;
let scrollbar_reserved = if overflow { 1u16 } else { 0 };
let mut try_it_rects: Vec<(Rect, String)> = Vec::new();
let mut docs_rect: Option<(Rect, String)> = None;
let visible: Vec<Line<'static>> = lines
.into_iter()
.zip(line_actions)
.enumerate()
.skip(scroll as usize)
.take(cap)
.enumerate()
.map(|(screen_row, (orig_idx, (line, action)))| {
let row_rect = Rect {
x: body_rect.x,
y: body_rect.y + screen_row as u16,
width: body_rect.width.saturating_sub(scrollbar_reserved),
height: 1,
};
if let Some(cmd) = action {
try_it_rects.push((row_rect, cmd));
}
if docs_line_idx == Some(orig_idx)
&& let Some(url) = ©.docs
{
docs_rect = Some((row_rect, url.clone()));
}
line
})
.collect();
app.rects.hover_help_try_it = try_it_rects;
app.rects.hover_help_docs = docs_rect;
if overflow {
let scrollbar_col = body_rect.x + body_rect.width.saturating_sub(1);
let content_rect = Rect {
width: body_rect.width.saturating_sub(1),
..body_rect
};
frame.render_widget(Paragraph::new(visible), content_rect);
let track_h = body_rect.height as usize;
let thumb_h = ((cap * track_h) / total_lines).max(1);
let thumb_y_off = if max_scroll == 0 {
0
} else {
((scroll as usize) * (track_h.saturating_sub(thumb_h))) / (max_scroll as usize)
};
for i in 0..track_h {
let is_thumb = i >= thumb_y_off && i < thumb_y_off + thumb_h;
let (glyph, color) = if is_thumb {
("┃", t.cyan)
} else {
("│", t.comment)
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
glyph,
Style::default().fg(color).bg(body_bg),
))),
Rect {
x: scrollbar_col,
y: body_rect.y + i as u16,
width: 1,
height: 1,
},
);
}
} else {
frame.render_widget(Paragraph::new(visible), body_rect);
}
}
fn spacer<'a>(bg: ratatui::style::Color) -> Line<'a> {
Line::from(Span::styled(" ", Style::default().bg(bg)))
}
fn pad_line(s: &str, width: usize) -> String {
let w = s.chars().count();
if w >= width {
s.to_string()
} else {
format!("{}{}", s, " ".repeat(width - w))
}
}
fn wrap_words(text: &str, width: usize) -> Vec<String> {
if width == 0 || text.is_empty() {
return vec![String::new()];
}
let mut out: Vec<String> = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let word_len = word.chars().count();
if word_len > width {
if !line.is_empty() {
out.push(std::mem::take(&mut line));
}
let mut chars = word.chars();
loop {
let chunk: String = chars.by_ref().take(width).collect();
if chunk.is_empty() {
break;
}
if chunk.chars().count() == width {
out.push(chunk);
} else {
line = chunk;
break;
}
}
continue;
}
let needed = if line.is_empty() {
word_len
} else {
line.chars().count() + 1 + word_len
};
if needed > width {
out.push(std::mem::take(&mut line));
line = word.to_string();
} else {
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
}
if !line.is_empty() {
out.push(line);
}
if out.is_empty() {
out.push(String::new());
}
out
}
fn debounced_help_copy(app: &mut App) -> crate::ui::info_view::InfoViewCopy {
let fresh = pick_help_copy(app);
if let Some(panel) = app.rects.hover_help_strip
&& let Some((mx, my)) = app.mouse_pos
&& crate::app::dispatch::contains(panel, mx, my)
&& let Some(committed) = app.hover_help_committed.clone()
{
app.hover_help_pending = None;
return committed;
}
let Some(committed) = app.hover_help_committed.clone() else {
app.hover_help_committed = Some(fresh.clone());
app.hover_help_pending = None;
return fresh;
};
if committed.title == fresh.title {
app.hover_help_pending = None;
return committed;
}
let now = std::time::Instant::now();
match &app.hover_help_pending {
Some((pending_copy, first_seen))
if pending_copy.title == fresh.title
&& first_seen.elapsed().as_millis() >= HOVER_HELP_DEBOUNCE_MS =>
{
app.hover_help_committed = Some(fresh.clone());
app.hover_help_pending = None;
app.hover_help_scroll = 0;
fresh
}
Some((pending_copy, _)) if pending_copy.title == fresh.title => {
committed
}
_ => {
app.hover_help_pending = Some((fresh.clone(), now));
committed
}
}
}
fn pick_help_copy(app: &App) -> crate::ui::info_view::InfoViewCopy {
use crate::ui::info_view::InfoViewCopy;
if let Some((chip, _)) = app.hover_chip {
let target = crate::ui::info_view::InfoViewTarget::Chip(chip);
if let Some(copy) = crate::ui::info_view_copy::lookup(app, &target) {
return copy;
}
if let Some((primary, secondary)) = crate::ui::tooltip::describe_text(chip, app) {
return InfoViewCopy {
title: primary,
body: secondary.unwrap_or_default(),
..Default::default()
};
}
}
if let Some(copy) = describe_focus_target_copy(app) {
return copy;
}
if let Some(cur) = app.active
&& let Some(pane) = app.panes.get(cur)
&& let Some((primary, secondary)) = describe_active_pane(pane)
{
return InfoViewCopy {
title: primary,
body: secondary.unwrap_or_default(),
..Default::default()
};
}
let (title, body) = match app.focus {
crate::focus::Focus::Tree => (
"Sidebar",
"Arrows or j/k walk rows. Enter opens the selection. Ctrl+Shift+P opens the palette.",
),
crate::focus::Focus::Pane => (
"Editor",
"Hover a chip, tab, or tree row for help. Ctrl+Shift+P opens the palette.",
),
crate::focus::Focus::RightPanel => (
"Right panel",
"Arrows walk rows. Enter jumps to the source. F6 cycles focus.",
),
crate::focus::Focus::BottomPanel => (
"Bottom panel",
"Arrows walk rows. Ctrl+Shift+J hides. F6 cycles focus.",
),
};
InfoViewCopy {
title: title.to_string(),
body: body.to_string(),
..Default::default()
}
}
fn describe_focus_target_copy(app: &App) -> Option<crate::ui::info_view::InfoViewCopy> {
use crate::ui::info_view::InfoViewCopy;
let (primary, secondary) = describe_focus_target(app)?;
Some(InfoViewCopy {
title: primary,
body: secondary.unwrap_or_default(),
..Default::default()
})
}
fn describe_focus_target(app: &App) -> Option<(String, Option<String>)> {
match app.focus {
crate::focus::Focus::Pane => None,
crate::focus::Focus::Tree => {
if let Some(section_hint) = section_focus_hint(app.active_section) {
return Some(section_hint);
}
if app.tree.cursor() == 0 {
return None;
}
let row = app.tree.selected_row()?;
let name = row
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| row.path.to_string_lossy().into_owned());
let target = crate::ui::info_view::InfoViewTarget::TreeRow {
label: name.clone(),
is_dir: row.is_dir,
};
if let Some(copy) = crate::ui::info_view_copy::lookup(app, &target) {
return Some(copy.to_flat_pair());
}
let (primary, secondary) = if row.is_dir {
(
format!("{name}/"),
Some("Directory. Enter or Right expands / opens. j/k walks rows.".to_string()),
)
} else {
let ext = row
.path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
.unwrap_or_default();
let lang = friendly_lang(&ext);
let primary_with_lang = if lang.is_empty() {
name
} else {
format!("{name} · {lang}")
};
(
primary_with_lang,
Some(
"File. Enter opens it in a new tab. Right-click for cut / copy / paste / rename."
.to_string(),
),
)
};
Some((primary, secondary))
}
crate::focus::Focus::RightPanel => {
let pane_idx = *app.right_panel_panes.get(app.right_panel_active_idx)?;
let pane = app.panes.get(pane_idx)?;
let (primary, _) = describe_active_pane(pane)?;
Some((
primary,
Some(
"Right-panel focus. Arrows walk rows. Enter jumps. F6 cycles focus."
.to_string(),
),
))
}
crate::focus::Focus::BottomPanel => {
let pane_idx = *app.bottom_panel_panes.get(app.bottom_panel_active_idx)?;
let pane = app.panes.get(pane_idx)?;
let (primary, _) = describe_active_pane(pane)?;
Some((
primary,
Some(
"Bottom-panel focus. Arrows walk rows. Ctrl+Shift+J hides. F6 cycles focus."
.to_string(),
),
))
}
}
}
fn section_focus_hint(section: crate::app::ActivitySection) -> Option<(String, Option<String>)> {
use crate::app::ActivitySection::*;
let (title, body) = match section {
Explorer | LauncherIcon(_) => return None,
Search => (
"Search",
"Workspace search. `/` filters. Enter jumps to the match.",
),
Git => (
"Git",
"Branch + worktree. Enter checks out. Right-click for stash / log / status.",
),
Debug => (
"Debug",
"Debug panel. F5 starts, Shift+F5 continues, F10 steps over, F11 steps in.",
),
Integrations => (
"Integrations",
"Installed integrations. Enter fires the command. Right-click for Configure / Uninstall.",
),
Sessions => (
"Sessions",
"Open Pty sessions. Click a tab to focus. `×` closes.",
),
Agents => (
"Agents",
"Claude / Codex dashboard. Space multi-selects, Enter opens, `k` kills.",
),
CloudAgents => (
"Cloud agents",
"ECS runner rows. Enter opens the run detail; right-click for CloudWatch / PR / copy runId.",
),
Http => (
"HTTP",
"Request workflow — `.http` / `.curl` browser, recent, envs. Enter opens a request.",
),
Notes => (
"Notes",
"Workspace scratch notes under `.mnml/notes/`. Enter opens, `+ New` creates.",
),
Todos => (
"Todos",
"Workspace TODO list. Enter jumps to the anchoring source line.",
),
Findings => (
"Findings",
"`.mnml/findings/*.md` viewer. Enter opens the finding as a preview pane.",
),
Mount(_) => (
"Integration mount",
"External integration pane hosted in the sidebar.",
),
};
Some((title.to_string(), Some(body.to_string())))
}
fn friendly_lang(ext: &str) -> String {
match ext {
"" => String::new(),
"rs" => "Rust".into(),
"ts" => "TypeScript".into(),
"tsx" => "TypeScript (JSX)".into(),
"js" => "JavaScript".into(),
"jsx" => "JavaScript (JSX)".into(),
"py" => "Python".into(),
"go" => "Go".into(),
"rb" => "Ruby".into(),
"java" => "Java".into(),
"kt" | "kts" => "Kotlin".into(),
"swift" => "Swift".into(),
"c" => "C".into(),
"cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => "C++".into(),
"h" => "C header".into(),
"cs" => "C#".into(),
"php" => "PHP".into(),
"sh" | "bash" | "zsh" => "Shell".into(),
"lua" => "Lua".into(),
"vim" => "Vim script".into(),
"md" | "markdown" => "Markdown".into(),
"json" => "JSON".into(),
"yaml" | "yml" => "YAML".into(),
"toml" => "TOML".into(),
"xml" => "XML".into(),
"html" | "htm" => "HTML".into(),
"css" => "CSS".into(),
"scss" | "sass" => "Sass".into(),
"sql" => "SQL".into(),
"dockerfile" => "Dockerfile".into(),
"makefile" | "mk" => "Makefile".into(),
"proto" => "Protobuf".into(),
"graphql" | "gql" => "GraphQL".into(),
"http" | "curl" | "rest" => "HTTP request".into(),
"svg" => "SVG".into(),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => "Image".into(),
"pdf" => "PDF".into(),
"txt" | "text" => "Text".into(),
_ => ext.to_ascii_uppercase(),
}
}
fn describe_active_pane(pane: &crate::pane::Pane) -> Option<(String, Option<String>)> {
use crate::pane::Pane;
match pane {
Pane::Editor(b) => Some(describe_editor_pane(pane, b)),
Pane::Request(_) => Some((
pane.title(),
Some("Request pane — Enter to send, Ctrl+S saves as .http/.curl.".into()),
)),
Pane::Pty(_) => Some((
pane.title(),
Some("Terminal pane — Ctrl+Alt+H to detach, Ctrl+Alt+K to kill.".into()),
)),
Pane::MdPreview(_) => Some((
pane.title(),
Some("Rendered markdown preview — click header chip to jump back to source.".into()),
)),
Pane::Ai(_) => Some((
pane.title(),
Some("Claude / Codex session — type at the bottom prompt.".into()),
)),
Pane::ClaudeAgents(p) => {
if let Some(row) = p.selected_row() {
let source = match row.source {
crate::claude_agents::AgentSource::Claude => "Claude Code",
crate::claude_agents::AgentSource::Codex => "Codex",
crate::claude_agents::AgentSource::Ecs => "ECS runner",
crate::claude_agents::AgentSource::AnthropicManaged => "Anthropic Managed",
};
let state = format!("{:?}", row.state);
let workspace = if row.workspace.is_empty() {
"(unknown)".to_string()
} else {
row.workspace.clone()
};
let short_id = row.session_id.chars().take(8).collect::<String>();
let primary = format!("{source} · {workspace} · {state} · {short_id}");
let secondary = Some(
"Agents dashboard — j/k walks rows, K kills, Enter drills in, / filters."
.to_string(),
);
Some((primary, secondary))
} else {
Some((
pane.title(),
Some(
"Agents dashboard — no sessions found. j/k walks rows once populated, / filters."
.into(),
),
))
}
}
_ => Some((pane.title(), None)),
}
}
fn describe_editor_pane(
pane: &crate::pane::Pane,
b: &crate::buffer::Buffer,
) -> (String, Option<String>) {
use crate::lsp::Severity;
let title = pane.title();
let (row, col) = b.editor.row_col();
let lang = b
.language_ext
.as_deref()
.map(|e| e.to_ascii_uppercase())
.unwrap_or_else(|| "TEXT".to_string());
let cursor_line = row as u32;
let sev_rank = |s: Severity| match s {
Severity::Error => 4,
Severity::Warning => 3,
Severity::Info => 2,
Severity::Hint => 1,
};
let mut best: Option<&crate::lsp::Diagnostic> = None;
for d in b.diagnostics.iter().chain(b.linter_diagnostics.iter()) {
if cursor_line >= d.range.start.line && cursor_line <= d.range.end.line {
match best {
None => best = Some(d),
Some(cur) if sev_rank(d.severity) > sev_rank(cur.severity) => best = Some(d),
_ => {}
}
}
}
if let Some(d) = best {
let sev_label = match d.severity {
Severity::Error => "Error",
Severity::Warning => "Warning",
Severity::Info => "Info",
Severity::Hint => "Hint",
};
let src = d
.source
.as_deref()
.filter(|s| !s.is_empty())
.map(|s| format!(" · {s}"))
.unwrap_or_default();
let msg = one_line_trunc(&d.message, 160);
let primary = format!("{sev_label} at L{}:{} · {title}{src}", row + 1, col + 1);
let secondary = Some(format!(
"{msg} · [Ctrl+.] Code actions · [<leader>ca] (vim) · [<leader>d] Diagnostics list",
));
return (primary, secondary);
}
let sym = b.editor.word_under_cursor();
if !sym.is_empty() && sym.chars().count() <= 48 {
let primary = format!("{sym} · {lang} · {title} · L{}:{}", row + 1, col + 1);
let secondary =
Some("[gd] Definition · [gr] References · [K] Hover · [F2] Rename".to_string());
return (primary, secondary);
}
let lines = b.editor.text().lines().count().max(1);
let dirty = if b.dirty { " · unsaved" } else { "" };
let primary = format!(
"{title} · {lang} · L{}:{} · {lines} lines{dirty}",
row + 1,
col + 1,
);
let secondary = if b.is_preview {
Some("Preview tab — first edit or double-click promotes it.".to_string())
} else if b.is_pinned {
Some("Pinned — stays at the front of the bufferline.".to_string())
} else {
Some(
"[gd] Definition · [gr] References · [Ctrl+.] Code actions · [Ctrl+P] Files"
.to_string(),
)
};
(primary, secondary)
}
fn one_line_trunc(s: &str, max: usize) -> String {
let mut out = String::with_capacity(s.len().min(max + 4));
let mut prev_ws = false;
for c in s.chars() {
if c.is_whitespace() {
if !prev_ws && !out.is_empty() {
out.push(' ');
prev_ws = true;
}
} else {
out.push(c);
prev_ws = false;
}
}
let trimmed = out.trim_end();
if trimmed.chars().count() > max {
let mut short: String = trimmed.chars().take(max).collect();
short.push('…');
short
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::wrap_words;
use super::*;
#[test]
fn wrap_preserves_word_boundaries() {
let out = wrap_words("the quick brown fox jumps over", 10);
for line in &out {
assert!(line.chars().count() <= 10, "line {line:?} exceeds width");
}
assert!(
out.join(" ")
.split_whitespace()
.eq("the quick brown fox jumps over".split_whitespace())
);
}
#[test]
fn wrap_handles_oversized_word_hard_break() {
let out = wrap_words("supercalifragilisticexpialidocious", 8);
for line in &out {
assert!(line.chars().count() <= 8);
}
assert_eq!(out.concat(), "supercalifragilisticexpialidocious");
}
#[test]
fn wrap_empty_input_returns_one_empty_line() {
assert_eq!(wrap_words("", 10), vec![String::new()]);
}
#[test]
fn wrap_zero_width_returns_one_empty_line() {
assert_eq!(wrap_words("hello world", 0), vec![String::new()]);
}
use super::friendly_lang;
#[test]
fn friendly_lang_known_extensions() {
assert_eq!(friendly_lang("rs"), "Rust");
assert_eq!(friendly_lang("tsx"), "TypeScript (JSX)");
assert_eq!(friendly_lang("py"), "Python");
assert_eq!(friendly_lang("go"), "Go");
assert_eq!(friendly_lang("md"), "Markdown");
assert_eq!(friendly_lang("yaml"), "YAML");
assert_eq!(friendly_lang("yml"), "YAML");
}
#[test]
fn friendly_lang_empty_ext_returns_empty() {
assert_eq!(friendly_lang(""), "");
}
#[test]
fn friendly_lang_unknown_ext_uppercased_fallback() {
assert_eq!(friendly_lang("xyz"), "XYZ");
}
use super::one_line_trunc;
#[test]
fn one_line_trunc_collapses_whitespace_runs() {
assert_eq!(
one_line_trunc("expected u32,\n found i64", 40),
"expected u32, found i64"
);
}
#[test]
fn one_line_trunc_truncates_with_ellipsis() {
let s = one_line_trunc(&"abcd".repeat(50), 10);
assert!(s.ends_with('…'));
assert_eq!(s.chars().count(), 11);
}
#[test]
fn one_line_trunc_short_input_unchanged() {
assert_eq!(one_line_trunc("short", 40), "short");
}
#[test]
fn draw_populates_try_it_click_rects_for_a_chip_with_links() {
use crate::app::App;
use crate::config::Config;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let d = tempfile::tempdir().unwrap();
let mut app = App::new(d.path().to_path_buf(), Config::default()).unwrap();
app.hover_chip = Some((crate::HoverChip::StatuslineMode, std::time::Instant::now()));
let area = Rect {
x: 0,
y: 0,
width: 30,
height: 20,
};
let mut term = Terminal::new(TestBackend::new(30, 20)).unwrap();
term.draw(|f| draw(f, &mut app, area)).unwrap();
assert_eq!(
app.rects.hover_help_try_it.len(),
1,
"StatuslineMode's one try_it link should produce one click rect"
);
let (rect, cmd_id) = &app.rects.hover_help_try_it[0];
assert_eq!(cmd_id, "editor.toggle_keymap");
assert!(
area.intersects(*rect),
"try_it rect must sit inside the panel area"
);
let (docs_rect, url) = app
.rects
.hover_help_docs
.as_ref()
.expect("StatuslineMode has a docs link");
assert!(url.starts_with("https://mnml.sh/manual/"));
assert!(area.intersects(*docs_rect));
}
#[test]
fn draw_clears_stale_try_it_rects_when_panel_shrinks_to_a_sliver() {
use crate::app::App;
use crate::config::Config;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let d = tempfile::tempdir().unwrap();
let mut app = App::new(d.path().to_path_buf(), Config::default()).unwrap();
app.hover_chip = Some((crate::HoverChip::StatuslineMode, std::time::Instant::now()));
let mut term = Terminal::new(TestBackend::new(30, 20)).unwrap();
term.draw(|f| {
draw(
f,
&mut app,
Rect {
x: 0,
y: 0,
width: 30,
height: 20,
},
)
})
.unwrap();
assert_eq!(app.rects.hover_help_try_it.len(), 1);
let mut term2 = Terminal::new(TestBackend::new(30, 1)).unwrap();
term2
.draw(|f| {
draw(
f,
&mut app,
Rect {
x: 0,
y: 0,
width: 30,
height: 1,
},
)
})
.unwrap();
assert!(app.rects.hover_help_try_it.is_empty());
}
}