use crate::RatatuiTheme;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Widget},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const LEFT_INDENT: u16 = 2;
const RIGHT_INSET: u16 = 1;
const HORIZONTAL_PADDING: u16 = 1;
const MIN_BOX_WIDTH: u16 = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AnnotationKind {
Comment,
Outdated,
Draft,
}
#[derive(Debug, Clone)]
pub(crate) enum AnnotationLine {
Top,
Body(String),
Bottom,
Compact(String),
}
#[derive(Debug, Clone)]
pub(crate) struct AnnotationBox {
kind: AnnotationKind,
title: String,
width: u16,
indent: u16,
lines: Vec<AnnotationLine>,
cursor: Option<(usize, u16)>,
}
impl AnnotationBox {
pub(crate) fn new(
kind: AnnotationKind,
title: &str,
body: &str,
cursor: Option<usize>,
available_width: u16,
) -> Self {
let indent = LEFT_INDENT.min(available_width);
let width = available_width
.saturating_sub(indent)
.saturating_sub(RIGHT_INSET);
if width < MIN_BOX_WIDTH {
let compact_width = available_width.saturating_sub(indent);
let prefix = format!("│ {title}: ");
let text = fit_to_width(
&format!("{prefix}{}", body.replace('\n', " ⏎ ")),
usize::from(compact_width),
);
let cursor = cursor.map(|cursor| {
let before = format!("{prefix}{}", body[..cursor].replace('\n', " ⏎ "));
(
0,
indent.saturating_add(
display_width(&before).min(compact_width.saturating_sub(1)),
),
)
});
return Self {
kind,
title: title.to_owned(),
width: compact_width,
indent,
lines: vec![AnnotationLine::Compact(text)],
cursor,
};
}
let body_width = width
.saturating_sub(2)
.saturating_sub(HORIZONTAL_PADDING.saturating_mul(2));
let (body_lines, wrapped_cursor) = wrap_with_cursor(body, usize::from(body_width), cursor);
let mut lines = Vec::with_capacity(body_lines.len() + 2);
lines.push(AnnotationLine::Top);
lines.extend(body_lines.into_iter().map(AnnotationLine::Body));
lines.push(AnnotationLine::Bottom);
let cursor = wrapped_cursor.map(|(row, column)| {
(
row + 1,
indent
.saturating_add(1)
.saturating_add(HORIZONTAL_PADDING)
.saturating_add(u16::try_from(column).unwrap_or(u16::MAX)),
)
});
Self {
kind,
title: title.to_owned(),
width,
indent,
lines,
cursor,
}
}
pub(crate) const fn kind(&self) -> AnnotationKind {
self.kind
}
pub(crate) fn title(&self) -> &str {
&self.title
}
pub(crate) const fn width(&self) -> u16 {
self.width
}
pub(crate) const fn indent(&self) -> u16 {
self.indent
}
pub(crate) fn lines(&self) -> &[AnnotationLine] {
&self.lines
}
pub(crate) fn cursor_line(&self) -> Option<usize> {
self.cursor.map(|(line, _)| line)
}
pub(crate) fn cursor_column(&self, line: usize) -> Option<u16> {
self.cursor
.filter(|(cursor_line, _)| *cursor_line == line)
.map(|(_, column)| column)
}
}
fn wrap_with_cursor(
text: &str,
width: usize,
cursor: Option<usize>,
) -> (Vec<String>, Option<(usize, usize)>) {
let width = width.max(1);
let mut lines = Vec::new();
let mut line = String::new();
let mut line_width = 0_usize;
let mut cursor_position = None;
for (offset, character) in text.char_indices() {
let character_width = character.width().unwrap_or(0);
if character != '\n' && line_width > 0 && line_width.saturating_add(character_width) > width
{
lines.push(std::mem::take(&mut line));
line_width = 0;
}
if cursor == Some(offset) {
cursor_position = Some((lines.len(), line_width));
}
if character == '\n' {
lines.push(std::mem::take(&mut line));
line_width = 0;
} else {
line.push(character);
line_width = line_width.saturating_add(character_width);
}
}
if cursor == Some(text.len()) && line_width == width {
lines.push(std::mem::take(&mut line));
line_width = 0;
}
if cursor == Some(text.len()) {
cursor_position = Some((lines.len(), line_width));
}
lines.push(line);
(lines, cursor_position)
}
fn fit_to_width(text: &str, width: usize) -> String {
let mut used = 0_usize;
text.chars()
.take_while(|character| {
let next = used.saturating_add(character.width().unwrap_or(0));
if next > width {
false
} else {
used = next;
true
}
})
.collect()
}
fn display_width(text: &str) -> u16 {
u16::try_from(text.width()).unwrap_or(u16::MAX)
}
pub(crate) fn render_annotation_line(
area: Rect,
buffer: &mut Buffer,
theme: &RatatuiTheme,
annotation: &AnnotationBox,
line: usize,
) {
let Some(line) = annotation.lines().get(line) else {
return;
};
let border_color = match annotation.kind() {
AnnotationKind::Outdated => theme.ui.text_muted,
AnnotationKind::Comment | AnnotationKind::Draft => theme.ui.accent,
};
let body_color = if annotation.kind() == AnnotationKind::Outdated {
theme.ui.text_muted
} else {
theme.ui.text
};
let border = Style::new().fg(border_color).bg(theme.ui.canvas);
let body = Style::new().fg(body_color).bg(theme.ui.canvas);
let box_area = Rect::new(
area.x.saturating_add(annotation.indent()),
area.y,
annotation
.width()
.min(area.width.saturating_sub(annotation.indent())),
1,
);
let width = usize::from(box_area.width);
if width == 0 {
return;
}
let rendered = match line {
AnnotationLine::Top => {
let available = width.saturating_sub(2);
let label = format!("─ {} ", annotation.title());
let label = fit_to_width(&label, available);
let fill = "─".repeat(available.saturating_sub(label.width()));
Line::from(vec![
Span::styled("╭", border),
Span::styled(
label,
border.add_modifier(if annotation.kind() == AnnotationKind::Draft {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
Span::styled(fill, border),
Span::styled("╮", border),
])
}
AnnotationLine::Body(text) => {
let body_width = width.saturating_sub(4);
let fill = " ".repeat(body_width.saturating_sub(text.width()));
Line::from(vec![
Span::styled("│ ", border),
Span::styled(text.clone(), body),
Span::styled(fill, body),
Span::styled(" │", border),
])
}
AnnotationLine::Bottom => {
Line::styled(format!("╰{}╯", "─".repeat(width.saturating_sub(2))), border)
}
AnnotationLine::Compact(text) => Line::from(vec![
Span::styled("│ ", border),
Span::styled(text.strip_prefix("│ ").unwrap_or(text).to_owned(), body),
]),
};
Paragraph::new(rendered).render(box_area, buffer);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wraps_wide_characters_and_maps_the_cursor() {
let (lines, cursor) = wrap_with_cursor("ab界cd", 4, Some(5));
assert_eq!(lines, ["ab界", "cd"]);
assert_eq!(cursor, Some((1, 0)));
}
#[test]
fn an_eof_cursor_after_a_full_row_moves_to_a_continuation_row() {
let (lines, cursor) = wrap_with_cursor("abcd", 4, Some(4));
assert_eq!(lines, ["abcd", ""]);
assert_eq!(cursor, Some((1, 0)));
}
#[test]
fn preserves_explicit_blank_lines() {
let (lines, _) = wrap_with_cursor("first\n\nlast", 20, None);
assert_eq!(lines, ["first", "", "last"]);
}
#[test]
fn very_narrow_areas_use_a_compact_line() {
let box_layout = AnnotationBox::new(
AnnotationKind::Draft,
"Draft",
"界界界",
Some("界界界".len()),
8,
);
assert!(matches!(
box_layout.lines.as_slice(),
[AnnotationLine::Compact(_)]
));
assert!(box_layout.cursor_column(0).is_some());
}
#[test]
fn box_has_horizontal_padding_and_border_rows() {
let box_layout = AnnotationBox::new(AnnotationKind::Comment, "Comment", "body", None, 30);
assert!(matches!(box_layout.lines[0], AnnotationLine::Top));
assert!(matches!(box_layout.lines[1], AnnotationLine::Body(ref body) if body == "body"));
assert!(matches!(box_layout.lines[2], AnnotationLine::Bottom));
assert_eq!(box_layout.indent, LEFT_INDENT);
}
}