use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PopupPosition {
AtCursor,
BelowCursor,
AboveCursor,
Fixed { x: u16, y: u16 },
Centered,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StyledSpan {
pub text: String,
pub style: Style,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StyledLine {
pub spans: Vec<StyledSpan>,
}
impl StyledLine {
pub fn new() -> Self {
Self { spans: Vec::new() }
}
pub fn push(&mut self, text: String, style: Style) {
self.spans.push(StyledSpan { text, style });
}
}
impl Default for StyledLine {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PopupContent {
Text(Vec<String>),
Markdown(Vec<StyledLine>),
List {
items: Vec<PopupListItem>,
selected: usize,
},
Custom(Vec<String>),
}
pub fn parse_markdown(text: &str, theme: &crate::view::theme::Theme) -> Vec<StyledLine> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(text, options);
let mut lines: Vec<StyledLine> = vec![StyledLine::new()];
let mut style_stack: Vec<Style> = vec![Style::default()];
let mut in_code_block = false;
let mut code_block_lang = String::new();
for event in parser {
match event {
Event::Start(tag) => {
match tag {
Tag::Strong => {
let current = *style_stack.last().unwrap_or(&Style::default());
style_stack.push(current.add_modifier(Modifier::BOLD));
}
Tag::Emphasis => {
let current = *style_stack.last().unwrap_or(&Style::default());
style_stack.push(current.add_modifier(Modifier::ITALIC));
}
Tag::Strikethrough => {
let current = *style_stack.last().unwrap_or(&Style::default());
style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
}
Tag::CodeBlock(kind) => {
in_code_block = true;
code_block_lang = match kind {
pulldown_cmark::CodeBlockKind::Fenced(lang) => lang.to_string(),
pulldown_cmark::CodeBlockKind::Indented => String::new(),
};
if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
lines.push(StyledLine::new());
}
}
Tag::Heading { .. } => {
let current = *style_stack.last().unwrap_or(&Style::default());
style_stack
.push(current.add_modifier(Modifier::BOLD).fg(theme.help_key_fg));
}
Tag::Link { .. } | Tag::Image { .. } => {
let current = *style_stack.last().unwrap_or(&Style::default());
style_stack
.push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
}
Tag::List(_) | Tag::Item => {
if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
lines.push(StyledLine::new());
}
}
Tag::Paragraph => {
if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
lines.push(StyledLine::new());
}
}
_ => {}
}
}
Event::End(tag_end) => {
match tag_end {
TagEnd::Strong
| TagEnd::Emphasis
| TagEnd::Strikethrough
| TagEnd::Heading(_)
| TagEnd::Link
| TagEnd::Image => {
style_stack.pop();
}
TagEnd::CodeBlock => {
in_code_block = false;
code_block_lang.clear();
lines.push(StyledLine::new());
}
TagEnd::Paragraph => {
lines.push(StyledLine::new());
}
TagEnd::Item => {
}
_ => {}
}
}
Event::Text(text) => {
let current_style = if in_code_block {
Style::default()
.fg(theme.help_key_fg)
.bg(theme.inline_code_bg)
} else {
*style_stack.last().unwrap_or(&Style::default())
};
for (i, part) in text.split('\n').enumerate() {
if i > 0 {
lines.push(StyledLine::new());
}
if !part.is_empty() {
if let Some(line) = lines.last_mut() {
line.push(part.to_string(), current_style);
}
}
}
}
Event::Code(code) => {
let style = Style::default()
.fg(theme.help_key_fg)
.bg(theme.inline_code_bg);
if let Some(line) = lines.last_mut() {
line.push(format!("`{}`", code), style);
}
}
Event::SoftBreak => {
if let Some(line) = lines.last_mut() {
line.push(" ".to_string(), Style::default());
}
}
Event::HardBreak => {
lines.push(StyledLine::new());
}
Event::Rule => {
lines.push(StyledLine::new());
if let Some(line) = lines.last_mut() {
line.push("─".repeat(40), Style::default().fg(Color::DarkGray));
}
lines.push(StyledLine::new());
}
_ => {}
}
}
while lines.last().map(|l| l.spans.is_empty()).unwrap_or(false) {
lines.pop();
}
lines
}
#[derive(Debug, Clone, PartialEq)]
pub struct PopupListItem {
pub text: String,
pub detail: Option<String>,
pub icon: Option<String>,
pub data: Option<String>,
}
impl PopupListItem {
pub fn new(text: String) -> Self {
Self {
text,
detail: None,
icon: None,
data: None,
}
}
pub fn with_detail(mut self, detail: String) -> Self {
self.detail = Some(detail);
self
}
pub fn with_icon(mut self, icon: String) -> Self {
self.icon = Some(icon);
self
}
pub fn with_data(mut self, data: String) -> Self {
self.data = Some(data);
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Popup {
pub title: Option<String>,
pub content: PopupContent,
pub position: PopupPosition,
pub width: u16,
pub max_height: u16,
pub bordered: bool,
pub border_style: Style,
pub background_style: Style,
pub scroll_offset: usize,
}
impl Popup {
pub fn text(content: Vec<String>, theme: &crate::view::theme::Theme) -> Self {
Self {
title: None,
content: PopupContent::Text(content),
position: PopupPosition::AtCursor,
width: 50,
max_height: 15,
bordered: true,
border_style: Style::default().fg(theme.popup_border_fg),
background_style: Style::default().bg(theme.popup_bg),
scroll_offset: 0,
}
}
pub fn markdown(markdown_text: &str, theme: &crate::view::theme::Theme) -> Self {
let styled_lines = parse_markdown(markdown_text, theme);
Self {
title: None,
content: PopupContent::Markdown(styled_lines),
position: PopupPosition::AtCursor,
width: 60, max_height: 20, bordered: true,
border_style: Style::default().fg(theme.popup_border_fg),
background_style: Style::default().bg(theme.popup_bg),
scroll_offset: 0,
}
}
pub fn list(items: Vec<PopupListItem>, theme: &crate::view::theme::Theme) -> Self {
Self {
title: None,
content: PopupContent::List { items, selected: 0 },
position: PopupPosition::AtCursor,
width: 50,
max_height: 15,
bordered: true,
border_style: Style::default().fg(theme.popup_border_fg),
background_style: Style::default().bg(theme.popup_bg),
scroll_offset: 0,
}
}
pub fn with_title(mut self, title: String) -> Self {
self.title = Some(title);
self
}
pub fn with_position(mut self, position: PopupPosition) -> Self {
self.position = position;
self
}
pub fn with_width(mut self, width: u16) -> Self {
self.width = width;
self
}
pub fn with_max_height(mut self, max_height: u16) -> Self {
self.max_height = max_height;
self
}
pub fn with_border_style(mut self, style: Style) -> Self {
self.border_style = style;
self
}
pub fn selected_item(&self) -> Option<&PopupListItem> {
match &self.content {
PopupContent::List { items, selected } => items.get(*selected),
_ => None,
}
}
pub fn select_next(&mut self) {
if let PopupContent::List { items, selected } = &mut self.content {
if *selected < items.len().saturating_sub(1) {
*selected += 1;
if *selected >= self.scroll_offset + self.max_height as usize {
self.scroll_offset = (*selected + 1).saturating_sub(self.max_height as usize);
}
}
}
}
pub fn select_prev(&mut self) {
if let PopupContent::List { items: _, selected } = &mut self.content {
if *selected > 0 {
*selected -= 1;
if *selected < self.scroll_offset {
self.scroll_offset = *selected;
}
}
}
}
pub fn page_down(&mut self) {
if let PopupContent::List { items, selected } = &mut self.content {
let page_size = self.max_height as usize;
*selected = (*selected + page_size).min(items.len().saturating_sub(1));
self.scroll_offset = (*selected + 1).saturating_sub(page_size);
} else {
self.scroll_offset += self.max_height as usize;
}
}
pub fn page_up(&mut self) {
if let PopupContent::List { items: _, selected } = &mut self.content {
let page_size = self.max_height as usize;
*selected = selected.saturating_sub(page_size);
self.scroll_offset = *selected;
} else {
self.scroll_offset = self.scroll_offset.saturating_sub(self.max_height as usize);
}
}
fn content_height(&self) -> u16 {
let content_lines = match &self.content {
PopupContent::Text(lines) => lines.len() as u16,
PopupContent::Markdown(lines) => lines.len() as u16,
PopupContent::List { items, .. } => items.len() as u16,
PopupContent::Custom(lines) => lines.len() as u16,
};
let border_height = if self.bordered { 2 } else { 0 };
content_lines + border_height
}
pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
match self.position {
PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
let (cursor_x, cursor_y) =
cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
let width = self.width.min(terminal_area.width);
let height = self
.content_height()
.min(self.max_height)
.min(terminal_area.height);
let x = if cursor_x + width > terminal_area.width {
terminal_area.width.saturating_sub(width)
} else {
cursor_x
};
let y = match self.position {
PopupPosition::AtCursor => cursor_y,
PopupPosition::BelowCursor => {
if cursor_y + 2 + height > terminal_area.height {
(cursor_y + 1).saturating_sub(height)
} else {
cursor_y + 2
}
}
PopupPosition::AboveCursor => {
(cursor_y + 1).saturating_sub(height)
}
_ => cursor_y,
};
Rect {
x,
y,
width,
height,
}
}
PopupPosition::Fixed { x, y } => {
let width = self.width.min(terminal_area.width);
let height = self
.content_height()
.min(self.max_height)
.min(terminal_area.height);
Rect {
x,
y,
width,
height,
}
}
PopupPosition::Centered => {
let width = self.width.min(terminal_area.width);
let height = self
.content_height()
.min(self.max_height)
.min(terminal_area.height);
let x = (terminal_area.width.saturating_sub(width)) / 2;
let y = (terminal_area.height.saturating_sub(height)) / 2;
Rect {
x,
y,
width,
height,
}
}
}
}
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
self.render_with_hover(frame, area, theme, None);
}
pub fn render_with_hover(
&self,
frame: &mut Frame,
area: Rect,
theme: &crate::view::theme::Theme,
hover_target: Option<&crate::app::HoverTarget>,
) {
frame.render_widget(Clear, area);
let block = if self.bordered {
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(self.border_style)
.style(self.background_style);
if let Some(title) = &self.title {
block = block.title(title.as_str());
}
block
} else {
Block::default().style(self.background_style)
};
let inner_area = block.inner(area);
frame.render_widget(block, area);
match &self.content {
PopupContent::Text(lines) => {
let visible_lines: Vec<Line> = lines
.iter()
.skip(self.scroll_offset)
.take(inner_area.height as usize)
.map(|line| Line::from(line.as_str()))
.collect();
let paragraph = Paragraph::new(visible_lines);
frame.render_widget(paragraph, inner_area);
}
PopupContent::Markdown(styled_lines) => {
let visible_lines: Vec<Line> = styled_lines
.iter()
.skip(self.scroll_offset)
.take(inner_area.height as usize)
.map(|styled_line| {
let spans: Vec<Span> = styled_line
.spans
.iter()
.map(|s| Span::styled(s.text.clone(), s.style))
.collect();
Line::from(spans)
})
.collect();
let paragraph = Paragraph::new(visible_lines);
frame.render_widget(paragraph, inner_area);
}
PopupContent::List { items, selected } => {
let list_items: Vec<ListItem> = items
.iter()
.enumerate()
.skip(self.scroll_offset)
.take(inner_area.height as usize)
.map(|(idx, item)| {
let mut spans = Vec::new();
if let Some(icon) = &item.icon {
spans.push(Span::raw(format!("{} ", icon)));
}
spans.push(Span::raw(&item.text));
if let Some(detail) = &item.detail {
spans.push(Span::styled(
format!(" {}", detail),
Style::default().fg(theme.help_separator_fg),
));
}
let is_hovered = matches!(
hover_target,
Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
);
let style = if idx == *selected {
Style::default()
.bg(theme.popup_selection_bg)
.add_modifier(Modifier::BOLD)
} else if is_hovered {
Style::default()
.bg(theme.menu_hover_bg)
.fg(theme.menu_hover_fg)
} else {
Style::default()
};
ListItem::new(Line::from(spans)).style(style)
})
.collect();
let list = List::new(list_items);
frame.render_widget(list, inner_area);
}
PopupContent::Custom(lines) => {
let visible_lines: Vec<Line> = lines
.iter()
.skip(self.scroll_offset)
.take(inner_area.height as usize)
.map(|line| Line::from(line.as_str()))
.collect();
let paragraph = Paragraph::new(visible_lines);
frame.render_widget(paragraph, inner_area);
}
}
}
}
#[derive(Debug, Clone)]
pub struct PopupManager {
popups: Vec<Popup>,
}
impl PopupManager {
pub fn new() -> Self {
Self { popups: Vec::new() }
}
pub fn show(&mut self, popup: Popup) {
self.popups.push(popup);
}
pub fn hide(&mut self) -> Option<Popup> {
self.popups.pop()
}
pub fn clear(&mut self) {
self.popups.clear();
}
pub fn top(&self) -> Option<&Popup> {
self.popups.last()
}
pub fn top_mut(&mut self) -> Option<&mut Popup> {
self.popups.last_mut()
}
pub fn is_visible(&self) -> bool {
!self.popups.is_empty()
}
pub fn all(&self) -> &[Popup] {
&self.popups
}
}
impl Default for PopupManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_popup_list_item() {
let item = PopupListItem::new("test".to_string())
.with_detail("detail".to_string())
.with_icon("📄".to_string());
assert_eq!(item.text, "test");
assert_eq!(item.detail, Some("detail".to_string()));
assert_eq!(item.icon, Some("📄".to_string()));
}
#[test]
fn test_popup_selection() {
let theme = crate::view::theme::Theme::dark();
let items = vec![
PopupListItem::new("item1".to_string()),
PopupListItem::new("item2".to_string()),
PopupListItem::new("item3".to_string()),
];
let mut popup = Popup::list(items, &theme);
assert_eq!(popup.selected_item().unwrap().text, "item1");
popup.select_next();
assert_eq!(popup.selected_item().unwrap().text, "item2");
popup.select_next();
assert_eq!(popup.selected_item().unwrap().text, "item3");
popup.select_next(); assert_eq!(popup.selected_item().unwrap().text, "item3");
popup.select_prev();
assert_eq!(popup.selected_item().unwrap().text, "item2");
popup.select_prev();
assert_eq!(popup.selected_item().unwrap().text, "item1");
popup.select_prev(); assert_eq!(popup.selected_item().unwrap().text, "item1");
}
#[test]
fn test_popup_manager() {
let theme = crate::view::theme::Theme::dark();
let mut manager = PopupManager::new();
assert!(!manager.is_visible());
assert_eq!(manager.top(), None);
let popup1 = Popup::text(vec!["test1".to_string()], &theme);
manager.show(popup1);
assert!(manager.is_visible());
assert_eq!(manager.all().len(), 1);
let popup2 = Popup::text(vec!["test2".to_string()], &theme);
manager.show(popup2);
assert_eq!(manager.all().len(), 2);
manager.hide();
assert_eq!(manager.all().len(), 1);
manager.clear();
assert!(!manager.is_visible());
assert_eq!(manager.all().len(), 0);
}
#[test]
fn test_popup_area_calculation() {
let theme = crate::view::theme::Theme::dark();
let terminal_area = Rect {
x: 0,
y: 0,
width: 100,
height: 50,
};
let popup = Popup::text(vec!["test".to_string()], &theme)
.with_width(30)
.with_max_height(10);
let popup_centered = popup.clone().with_position(PopupPosition::Centered);
let area = popup_centered.calculate_area(terminal_area, None);
assert_eq!(area.width, 30);
assert_eq!(area.height, 3);
assert_eq!(area.x, (100 - 30) / 2);
assert_eq!(area.y, (50 - 3) / 2);
let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
assert_eq!(area.x, 20);
assert_eq!(area.y, 12); }
}