use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Widget},
};
const MAX_WIDTH: u16 = 70; const MAX_LINES: u16 = 16;
pub(crate) struct HoverWidget<'a> {
pub text: &'a str,
pub anchor_x: u16,
pub anchor_y: u16,
pub terminal_area: Rect,
}
impl Widget for HoverWidget<'_> {
fn render(self, _area: Rect, buf: &mut Buffer) {
let term_w = self.terminal_area.width;
let term_h = self.terminal_area.height;
let content_width = (MAX_WIDTH.saturating_sub(2)) as usize;
let md_lines = render_markdown(self.text);
let wrapped = wrap_styled_lines(md_lines, content_width);
if wrapped.is_empty() {
return;
}
let content_lines = wrapped.len().min((MAX_LINES.saturating_sub(2)) as usize);
let box_w = wrapped
.iter()
.take(content_lines)
.map(|l| line_display_width(l))
.max()
.unwrap_or(0) as u16
+ 2;
let box_w = box_w.min(MAX_WIDTH).min(term_w);
let box_h = content_lines as u16 + 2;
let top = if self.anchor_y >= box_h {
self.anchor_y - box_h
} else {
self.anchor_y + 1
};
let top = top.min(term_h.saturating_sub(box_h));
let left = self.anchor_x.min(term_w.saturating_sub(box_w));
let area = Rect { x: left, y: top, width: box_w, height: box_h };
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
block.render(area, buf);
for (i, line) in wrapped.iter().take(content_lines).enumerate() {
let y = inner.y + i as u16;
if y >= term_h {
break;
}
let mut x = inner.x;
for span in &line.spans {
for ch in span.content.chars() {
if x >= inner.x + inner.width || x >= term_w {
break;
}
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_char(ch);
cell.set_style(span.style);
}
x += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
}
}
}
}
}
pub(crate) fn render_markdown(text: &str) -> Vec<Line<'static>> {
let normal = Style::default().fg(Color::White);
let code_fg = Style::default().fg(Color::Cyan);
let code_block_bg = Style::default().fg(Color::Cyan).bg(Color::Rgb(30, 30, 40));
let heading_style = Style::default().fg(Color::White).add_modifier(Modifier::BOLD);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut in_code_block = false;
for raw in text.lines() {
if raw.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
lines.push(Line::from(Span::styled(raw.to_owned(), code_block_bg)));
continue;
}
if raw.starts_with('#') {
let stripped = raw.trim_start_matches('#').trim();
if stripped.is_empty() {
lines.push(Line::default());
} else {
lines.push(Line::from(Span::styled(stripped.to_owned(), heading_style)));
}
continue;
}
let trimmed = raw.trim();
if trimmed.len() >= 3
&& (trimmed.chars().all(|c| c == '-')
|| trimmed.chars().all(|c| c == '*')
|| trimmed.chars().all(|c| c == '_'))
{
lines.push(Line::default());
continue;
}
if trimmed.is_empty() {
lines.push(Line::default());
continue;
}
lines.push(parse_inline(raw, normal, code_fg));
}
while lines.first().map(|l| l.spans.is_empty()).unwrap_or(false) {
lines.remove(0);
}
while lines.last().map(|l| l.spans.is_empty()).unwrap_or(false) {
lines.pop();
}
lines
}
fn parse_inline(line: &str, normal: Style, code_fg: Style) -> Line<'static> {
let bold_style = normal.add_modifier(Modifier::BOLD);
let italic_style = normal.add_modifier(Modifier::ITALIC);
let chars: Vec<char> = line.chars().collect();
let n = chars.len();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut i = 0usize;
let mut buf = String::new();
macro_rules! flush {
() => {
if !buf.is_empty() {
spans.push(Span::styled(std::mem::take(&mut buf), normal));
}
};
}
while i < n {
if chars[i] == '`' {
flush!();
let start = i + 1;
if let Some(p) = chars[start..].iter().position(|&c| c == '`') {
let code: String = chars[start..start + p].iter().collect();
spans.push(Span::styled(code, code_fg));
i = start + p + 1;
continue;
}
}
if i + 1 < n && chars[i] == '*' && chars[i + 1] == '*' {
flush!();
let start = i + 2;
if let Some(p) = chars[start..].windows(2).position(|w| w == ['*', '*']) {
let bold_text: String = chars[start..start + p].iter().collect();
spans.push(Span::styled(bold_text, bold_style));
i = start + p + 2;
continue;
}
}
if (chars[i] == '*' || chars[i] == '_') && (i == 0 || chars[i - 1] != chars[i]) {
let delim = chars[i];
flush!();
let start = i + 1;
if let Some(p) = chars[start..].iter().position(|&c| c == delim) {
let ital_text: String = chars[start..start + p].iter().collect();
spans.push(Span::styled(ital_text, italic_style));
i = start + p + 1;
continue;
}
}
buf.push(chars[i]);
i += 1;
}
flush!();
if spans.is_empty() {
Line::from(Span::styled(line.to_owned(), normal))
} else {
Line::from(spans)
}
}
fn wrap_styled_lines(lines: Vec<Line<'static>>, max_width: usize) -> Vec<Line<'static>> {
let mut out = Vec::new();
for line in lines {
if line.spans.is_empty() {
out.push(Line::default());
continue;
}
let chars: Vec<(char, Style)> = line.spans
.iter()
.flat_map(|s| s.content.chars().map(move |c| (c, s.style)))
.collect();
let mut current: Vec<(char, Style)> = Vec::new();
let mut current_width = 0usize;
let mut word_buf: Vec<(char, Style)> = Vec::new();
let mut word_width = 0usize;
for (ch, sty) in &chars {
if *ch == ' ' {
if !word_buf.is_empty() {
let needed = if current_width == 0 { word_width } else { 1 + word_width };
if current_width + needed > max_width && !current.is_empty() {
out.push(chars_to_line(¤t));
current.clear();
current_width = 0;
}
if current_width > 0 {
current.push((' ', Style::default()));
current_width += 1;
}
current.extend_from_slice(&word_buf);
current_width += word_width;
word_buf.clear();
word_width = 0;
}
} else {
word_buf.push((*ch, *sty));
word_width += unicode_width::UnicodeWidthChar::width(*ch).unwrap_or(1);
}
}
if !word_buf.is_empty() {
let needed = if current_width == 0 { word_width } else { 1 + word_width };
if current_width + needed > max_width && !current.is_empty() {
out.push(chars_to_line(¤t));
current.clear();
current_width = 0;
}
if current_width > 0 {
current.push((' ', Style::default()));
}
current.extend_from_slice(&word_buf);
}
if !current.is_empty() {
out.push(chars_to_line(¤t));
}
}
out
}
fn chars_to_line(chars: &[(char, Style)]) -> Line<'static> {
if chars.is_empty() {
return Line::default();
}
let mut spans: Vec<Span<'static>> = Vec::new();
let mut buf = String::new();
let mut cur_style = chars[0].1;
for &(ch, sty) in chars {
if sty != cur_style {
if !buf.is_empty() {
spans.push(Span::styled(std::mem::take(&mut buf), cur_style));
}
cur_style = sty;
}
buf.push(ch);
}
if !buf.is_empty() {
spans.push(Span::styled(buf, cur_style));
}
Line::from(spans)
}
fn line_display_width(line: &Line<'_>) -> usize {
line.spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
fn rendered_texts(md: &str) -> Vec<String> {
render_markdown(md)
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>())
.collect()
}
#[test]
fn plain_text_passthrough() {
assert_eq!(rendered_texts("hello world"), ["hello world"]);
}
#[test]
fn blank_lines_preserved() {
let t = rendered_texts("first\n\nsecond");
assert_eq!(t, ["first", "", "second"]);
}
#[test]
fn fenced_code_block_styled() {
let md = "```rust\nfn foo() {}\n```";
let lines = render_markdown(md);
assert_eq!(lines.len(), 1, "fence delimiters stripped");
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "fn foo() {}");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
}
#[test]
fn fenced_code_block_followed_by_blank() {
let md = "```\ncode\n```\n\nafter";
let lines = render_markdown(md);
assert_eq!(lines.len(), 3);
assert!(lines[1].spans.is_empty());
}
#[test]
fn heading_stripped_and_bold() {
let lines = render_markdown("## My Section");
assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "My Section");
assert!(lines[0].spans[0].style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn inline_code_styled() {
let lines = render_markdown("use `foo()` here");
let code_span = lines[0].spans.iter().find(|s| s.content == "foo()").unwrap();
assert_eq!(code_span.style.fg, Some(Color::Cyan));
}
#[test]
fn bold_text_styled() {
let lines = render_markdown("some **bold** text");
let bold_span = lines[0].spans.iter().find(|s| s.content == "bold").unwrap();
assert!(bold_span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn italic_text_styled() {
let lines = render_markdown("some *italic* text");
let ital_span = lines[0].spans.iter().find(|s| s.content == "italic").unwrap();
assert!(ital_span.style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn horizontal_rule_becomes_blank() {
let lines = render_markdown("before\n---\nafter");
assert_eq!(lines.len(), 3);
assert!(lines[1].spans.is_empty(), "hr becomes blank line");
}
#[test]
fn empty_input_produces_no_lines() {
assert!(render_markdown("").is_empty());
assert!(render_markdown(" \n\n ").is_empty());
}
#[test]
fn realistic_rust_hover() {
let md = "```rust\npub fn greet(name: &str) -> String\n```\n\nGreets the given **name**.";
let lines = render_markdown(md);
assert!(lines.len() >= 3, "code + blank + paragraph");
let code_text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(code_text.contains("greet"));
assert!(lines[1].spans.is_empty(), "blank separator after code block");
}
}