use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
use crate::app::App;
use crate::tooltip::{get_operator_content, get_tooltip_content};
use crate::widgets::popup;
const TOOLTIP_MIN_WIDTH: u16 = 40;
const TOOLTIP_MAX_WIDTH: u16 = 90;
const TOOLTIP_BORDER_HEIGHT: u16 = 2;
const TOOLTIP_BORDER_WIDTH: u16 = 4; const TOOLTIP_MIN_HEIGHT: u16 = 8;
const TOOLTIP_MAX_HEIGHT: u16 = 18;
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if text.len() <= max_width {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
if current_line.is_empty() {
current_line = word.to_string();
} else if current_line.len() + 1 + word.len() <= max_width {
current_line.push(' ');
current_line.push_str(word);
} else {
lines.push(current_line);
current_line = word.to_string();
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.len() > 2 {
lines.truncate(2);
}
lines
}
pub fn render_popup(app: &App, frame: &mut Frame, input_area: Rect) {
let (title_prefix, name, content) = if let Some(func) = &app.tooltip.current_function {
if let Some(c) = get_tooltip_content(func) {
("fn", func.as_str(), c)
} else {
return;
}
} else if let Some(op) = &app.tooltip.current_operator {
if let Some(c) = get_operator_content(op) {
("operator", op.as_str(), c)
} else {
return;
}
} else {
return;
};
let parsed_examples: Vec<(&str, &str)> = content
.examples
.iter()
.map(|e| {
if let Some(idx) = e.find('#') {
(e[..idx].trim_end(), e[idx + 1..].trim_start())
} else {
(*e, "")
}
})
.collect();
let max_code_width = parsed_examples
.iter()
.map(|(code, _)| code.len())
.max()
.unwrap_or(0);
let description_width = content.description.len();
let max_example_width = parsed_examples
.iter()
.map(|(code, desc)| {
if desc.is_empty() {
code.len() + 2 } else {
max_code_width + 3 + desc.len() + 2 }
})
.max()
.unwrap_or(0);
let dismiss_hint_len = 19; let title_width = title_prefix.len() + 2 + name.len() + dismiss_hint_len + 4;
let content_width = description_width.max(max_example_width).max(title_width);
let popup_width =
((content_width as u16) + TOOLTIP_BORDER_WIDTH).clamp(TOOLTIP_MIN_WIDTH, TOOLTIP_MAX_WIDTH);
let tip_available_width = (popup_width as usize).saturating_sub(6); let wrapped_tip_lines: Vec<String> = if let Some(tip) = content.tip {
wrap_text(tip, tip_available_width)
} else {
Vec::new()
};
let tip_line_count = wrapped_tip_lines.len() as u16;
let example_count = parsed_examples.len() as u16;
let tip_height = if content.tip.is_some() {
1 + tip_line_count } else {
0
};
let content_height = 1 + 1 + example_count + tip_height; let popup_height =
(content_height + TOOLTIP_BORDER_HEIGHT).clamp(TOOLTIP_MIN_HEIGHT, TOOLTIP_MAX_HEIGHT);
let frame_area = frame.area();
let max_allowed_width = (frame_area.width * 3) / 4;
let final_width = popup_width.min(max_allowed_width);
let popup_x = frame_area.width.saturating_sub(final_width + 2);
let popup_y = input_area.y.saturating_sub(popup_height);
let popup_area = Rect {
x: popup_x,
y: popup_y,
width: final_width,
height: popup_height.min(input_area.y),
};
popup::clear_area(frame, popup_area);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![Span::styled(
content.description,
Style::default().fg(Color::White),
)]));
lines.push(Line::from(""));
for (code, desc) in &parsed_examples {
if desc.is_empty() {
lines.push(Line::from(vec![Span::styled(
format!(" {}", code),
Style::default().fg(Color::Cyan),
)]));
} else {
let padded_code = format!("{:width$}", code, width = max_code_width);
lines.push(Line::from(vec![
Span::styled(
format!(" {}", padded_code),
Style::default().fg(Color::Cyan),
),
Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
Span::styled(*desc, Style::default().fg(Color::Gray)),
]));
}
}
if content.tip.is_some() && !wrapped_tip_lines.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("💡 ", Style::default()),
Span::styled(
wrapped_tip_lines[0].clone(),
Style::default().fg(Color::Yellow),
),
]));
for line in wrapped_tip_lines.iter().skip(1) {
lines.push(Line::from(vec![
Span::raw(" "), Span::styled(line.clone(), Style::default().fg(Color::Yellow)),
]));
}
}
let text = Text::from(lines);
let title = Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{}: {}", title_prefix, name),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
let dismiss_hint = Line::from(vec![Span::styled(
" Ctrl+T to dismiss ",
Style::default().fg(Color::DarkGray),
)]);
let popup_widget = Paragraph::new(text).block(
Block::default()
.borders(Borders::ALL)
.title(title)
.title_top(dismiss_hint.alignment(ratatui::layout::Alignment::Right))
.border_style(Style::default().fg(Color::Magenta))
.style(Style::default().bg(Color::Black)),
);
frame.render_widget(popup_widget, popup_area);
}
#[cfg(test)]
pub fn format_tooltip_title(is_function: bool, name: &str) -> String {
if is_function {
format!("fn: {}", name)
} else {
format!("operator: {}", name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::autocomplete::jq_functions::JQ_FUNCTION_METADATA;
use crate::tooltip::operator_content::OPERATOR_CONTENT;
use proptest::prelude::*;
#[test]
fn test_format_tooltip_title_function() {
assert_eq!(format_tooltip_title(true, "select"), "fn: select");
assert_eq!(format_tooltip_title(true, "map"), "fn: map");
assert_eq!(format_tooltip_title(true, "sort_by"), "fn: sort_by");
}
#[test]
fn test_format_tooltip_title_operator() {
assert_eq!(format_tooltip_title(false, "//"), "operator: //");
assert_eq!(format_tooltip_title(false, "|="), "operator: |=");
assert_eq!(format_tooltip_title(false, "//="), "operator: //=");
assert_eq!(format_tooltip_title(false, ".."), "operator: ..");
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_function_title_format(func_index in 0usize..JQ_FUNCTION_METADATA.len()) {
let func = &JQ_FUNCTION_METADATA[func_index];
let func_name = func.name;
let title = format_tooltip_title(true, func_name);
prop_assert!(
title.starts_with("fn: "),
"Function title '{}' should start with 'fn: '",
title
);
prop_assert!(
title.ends_with(func_name),
"Function title '{}' should end with function name '{}'",
title,
func_name
);
let expected = format!("fn: {}", func_name);
prop_assert_eq!(
title,
expected,
"Function title should be exactly 'fn: {}'",
func_name
);
}
#[test]
fn prop_operator_title_format(op_index in 0usize..OPERATOR_CONTENT.len()) {
let op = &OPERATOR_CONTENT[op_index];
let op_name = op.function;
let title = format_tooltip_title(false, op_name);
prop_assert!(
title.starts_with("operator: "),
"Operator title '{}' should start with 'operator: '",
title
);
prop_assert!(
title.ends_with(op_name),
"Operator title '{}' should end with operator '{}'",
title,
op_name
);
let expected = format!("operator: {}", op_name);
prop_assert_eq!(
title,
expected,
"Operator title should be exactly 'operator: {}'",
op_name
);
}
}
}