Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 157 additions & 56 deletions src/ui/components/issue_conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,19 @@ use rat_cursor::HasScreenCursor;
use rat_widget::{
event::{HandleEvent, Outcome, TextOutcome, ct_event},
focus::{FocusBuilder, FocusFlag, HasFocus, Navigation},
line_number::{LineNumberState, LineNumbers},
list::{ListState, selection::RowSelection},
paragraph::{Paragraph, ParagraphState},
textarea::{TextArea, TextAreaState, TextWrap},
};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style, Stylize},
layout::{Rect, Spacing},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{self, Block, ListItem, StatefulWidget, Widget},
widgets::{self, Block, Borders, ListItem, Padding, StatefulWidget, Widget},
};
use ratatui_macros::{horizontal, line, span, vertical};
use ratatui_macros::{horizontal, line, vertical};
use std::{
collections::{HashMap, HashSet},
sync::{Arc, OnceLock, RwLock},
Expand Down Expand Up @@ -170,7 +171,7 @@ pub struct TimelineEventView {
}

impl TimelineEventView {
fn from_api(event: TimelineEvent, fallback_id: u64) -> Option<Self> {
pub(crate) fn from_api(event: TimelineEvent, fallback_id: u64) -> Option<Self> {
if matches!(
event.event,
IssueEvent::Commented | IssueEvent::LineCommented | IssueEvent::CommentDeleted
Expand Down Expand Up @@ -210,6 +211,7 @@ impl TimelineEventView {

pub struct IssueConversation {
title: Option<Arc<str>>,
ln_state: LineNumberState,
action_tx: Option<tokio::sync::mpsc::Sender<Action>>,
current: Option<IssueConversationSeed>,
cache_number: Option<u64>,
Expand Down Expand Up @@ -264,13 +266,13 @@ enum MessageKey {
}

#[derive(Debug, Clone, Default)]
struct MarkdownRender {
lines: Vec<Line<'static>>,
links: Vec<RenderedLink>,
pub(crate) struct MarkdownRender {
pub(crate) lines: Vec<Line<'static>>,
pub(crate) links: Vec<RenderedLink>,
}

#[derive(Debug, Clone)]
struct RenderedLink {
pub(crate) struct RenderedLink {
line: usize,
col: usize,
label: String,
Expand Down Expand Up @@ -314,6 +316,7 @@ impl IssueConversation {
action_tx: None,
current: None,
cache_number: None,
ln_state: LineNumberState::default(),
cache_comments: Vec::new(),
timeline_cache_number: None,
cache_timeline: Vec::new(),
Expand Down Expand Up @@ -358,48 +361,40 @@ impl IssueConversation {
return;
}
self.area = area.main_content;
let title = self.title.clone().unwrap_or_default();
let wrapped_title = wrap(&title, area.main_content.width.saturating_sub(2) as usize);
let title_para_height = wrapped_title.len() as u16 + 2;
let last_item = wrapped_title.last();
let last_line = last_item
.as_ref()
.map(|l| {
line![
l.to_string(),
span!(
" #{}",
self.current.as_ref().map(|s| s.number).unwrap_or_default()
)
.dim()
]
})
.unwrap_or_else(|| Line::from(""));
let wrapped_title_len = wrapped_title.len() as u16;
let title_para = Text::from_iter(
wrapped_title
.into_iter()
.take(wrapped_title_len as usize - 1)
.map(Line::from)
.chain(std::iter::once(last_line)),
);
let mut title = self.title.clone().unwrap_or_default().to_string();
title.push_str(&format!(
" #{}",
self.current.as_ref().map(|s| s.number).unwrap_or_default()
));
let title = title.trim();
let wrapped_title = wrap(title, area.main_content.width.saturating_sub(2) as usize);
let title_para_height = wrapped_title.len() as u16 + 1;
let title_para = Text::from_iter(wrapped_title);

let areas = vertical![==title_para_height, *=1, ==5].split(area.main_content);
let title_area = areas[0];
let content_area = areas[1];
let input_area = areas[2];
let content_split = horizontal![*=1, *=1].split(content_area);
let content_split = horizontal![*=1, *=1]
.spacing(Spacing::Overlap(1))
.split(content_area);
let list_area = content_split[0];
let body_area = content_split[1];
let items = self.build_items(list_area, body_area);

let title_widget = widgets::Paragraph::new(title_para)
.block(Block::bordered().border_type(ratatui::widgets::BorderType::Rounded))
.block(
Block::default()
.padding(Padding::horizontal(1))
.borders(Borders::BOTTOM)
.merge_borders(ratatui::symbols::merge::MergeStrategy::Exact),
)
.style(Style::default().add_modifier(Modifier::BOLD));
title_widget.render(title_area, buf);

let mut list_block = Block::bordered()
.border_type(ratatui::widgets::BorderType::Rounded)
let mut list_block = Block::default()
.borders(Borders::RIGHT)
.merge_borders(ratatui::symbols::merge::MergeStrategy::Exact)
.border_style(get_border_style(&self.list_state));

if !self.is_loading_current() {
Expand Down Expand Up @@ -449,13 +444,26 @@ impl IssueConversation {

match self.textbox_state {
InputState::Input => {
let [line_numbers, input_area] = horizontal![==self.input_state.len_lines().checked_ilog10().unwrap_or(0) as u16 + 2, *=1].areas(input_area);
let ln_block = Block::default()
.borders(Borders::TOP)
.merge_borders(ratatui::symbols::merge::MergeStrategy::Exact)
.border_style(get_border_style(&self.input_state));
let ln = LineNumbers::new()
.with_textarea(&self.input_state)
.block(ln_block)
.style(Style::default().dim());
ln.render(line_numbers, buf, &mut self.ln_state);

let input_title = if let Some(err) = &self.post_error {
format!("Comment (Ctrl+Enter to send) | {err}")
} else {
"Comment (Ctrl+Enter to send)".to_string()
};
let mut input_block = Block::bordered()
.border_type(ratatui::widgets::BorderType::Rounded)
let mut input_block = Block::default()
.borders(Borders::TOP)
.merge_borders(ratatui::symbols::merge::MergeStrategy::Exact)
.padding(Padding::horizontal(1))
.border_style(get_border_style(&self.input_state));
if !self.posting {
input_block = input_block.title(input_title);
Expand All @@ -470,8 +478,10 @@ impl IssueConversation {
render_markdown_lines(&self.input_state.text(), self.markdown_width, 2);
let para = Paragraph::new(rendered)
.block(
Block::bordered()
.border_type(ratatui::widgets::BorderType::Rounded)
Block::default()
.borders(Borders::TOP)
.merge_borders(ratatui::symbols::merge::MergeStrategy::Exact)
.padding(Padding::horizontal(1))
.border_style(get_border_style(&self.paragraph_state))
.title("Preview"),
)
Expand Down Expand Up @@ -636,8 +646,9 @@ impl IssueConversation {

let body = Paragraph::new(body_lines)
.block(
Block::bordered()
.border_type(ratatui::widgets::BorderType::Rounded)
Block::default()
.borders(Borders::LEFT)
.merge_borders(ratatui::symbols::merge::MergeStrategy::Exact)
.border_style(get_border_style(&self.body_paragraph_state))
.title(if self.screen == MainScreen::DetailsFullscreen {
"Message Body (PageUp/PageDown/Home/End | f/Esc: exit fullscreen)"
Expand Down Expand Up @@ -2441,7 +2452,7 @@ pub(crate) fn render_markdown_lines(text: &str, width: usize, indent: usize) ->
render_markdown(text, width, indent).lines
}

fn render_markdown(text: &str, width: usize, indent: usize) -> MarkdownRender {
pub(crate) fn render_markdown(text: &str, width: usize, indent: usize) -> MarkdownRender {
let mut renderer = MarkdownRenderer::new(width, indent);
let options = Options::ENABLE_GFM
| Options::ENABLE_STRIKETHROUGH
Expand Down Expand Up @@ -2486,11 +2497,56 @@ struct MarkdownRenderer {
in_code_block: bool,
code_block_lang: Option<String>,
code_block_buf: String,
list_prefix: Option<String>,
item_prefix: Option<ListPrefix>,
pending_space: bool,
active_link_url: Option<String>,
}

#[derive(Debug, Clone)]
struct ListPrefix {
first_line: String,
continuation: String,
first_line_pending: bool,
}

impl ListPrefix {
fn bullet() -> Self {
Self::new("• ".to_string())
}

fn task(checked: bool) -> Self {
let prefix = if checked { "[x] " } else { "[ ] " };
Self::new(prefix.to_string())
}

fn new(first_line: String) -> Self {
let continuation = " ".repeat(display_width(&first_line));
Self {
first_line,
continuation,
first_line_pending: true,
}
}

fn current_text(&self) -> &str {
if self.first_line_pending {
&self.first_line
} else {
&self.continuation
}
}

fn current_width(&self) -> usize {
display_width(self.current_text())
}

fn take_for_line(&mut self) -> String {
let prefix = self.current_text().to_string();
self.first_line_pending = false;
prefix
}
}

#[derive(Clone, Copy)]
struct AdmonitionStyle {
marker: &'static str,
Expand Down Expand Up @@ -2553,7 +2609,7 @@ impl MarkdownRenderer {
in_code_block: false,
code_block_lang: None,
code_block_buf: String::new(),
list_prefix: None,
item_prefix: None,
pending_space: false,
active_link_url: None,
}
Expand Down Expand Up @@ -2593,7 +2649,7 @@ impl MarkdownRenderer {
}
Tag::Item => {
self.flush_line();
self.list_prefix = Some("• ".to_string());
self.item_prefix = Some(ListPrefix::bullet());
}
_ => {}
}
Expand Down Expand Up @@ -2633,7 +2689,7 @@ impl MarkdownRenderer {
}
TagEnd::Item => {
self.flush_line();
self.list_prefix = None;
self.item_prefix = None;
}
TagEnd::Paragraph => {
self.flush_line();
Expand Down Expand Up @@ -2705,8 +2761,8 @@ impl MarkdownRenderer {

fn task_list_marker(&mut self, checked: bool) {
self.ensure_admonition_header();
let marker = if checked { "[x] " } else { "[ ] " };
self.push_text(marker, self.current_style);
self.item_prefix = Some(ListPrefix::task(checked));
self.pending_space = false;
}

fn rule(&mut self) {
Expand Down Expand Up @@ -2912,9 +2968,10 @@ impl MarkdownRenderer {
.unwrap_or_else(|| Style::new().fg(Color::DarkGray));
self.current_line.push(Span::styled("│ ", border_style));
}
if let Some(prefix) = &self.list_prefix {
self.current_width += display_width(prefix);
self.current_line.push(Span::raw(prefix.clone()));
if let Some(prefix) = self.item_prefix.as_mut() {
let prefix = prefix.take_for_line();
self.current_width += display_width(&prefix);
self.current_line.push(Span::raw(prefix));
}
}

Expand All @@ -2923,8 +2980,8 @@ impl MarkdownRenderer {
if self.in_block_quote {
width += 2;
}
if let Some(prefix) = &self.list_prefix {
width += display_width(prefix);
if let Some(prefix) = &self.item_prefix {
width += prefix.current_width();
}
width
}
Expand Down Expand Up @@ -3079,6 +3136,19 @@ mod tests {
.collect()
}

fn all_line_text(rendered: &super::MarkdownRender) -> Vec<String> {
rendered
.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
})
.collect()
}

#[test]
fn extracts_link_segments_with_urls() {
let rendered = render_markdown("Go to [ratatui docs](https://github.com/ratatui/).", 80, 0);
Expand Down Expand Up @@ -3111,4 +3181,35 @@ mod tests {
.all(|link| !link.label.starts_with(' ') && !link.label.ends_with(' '))
);
}

#[test]
fn renders_unchecked_checklist_without_bullet_prefix() {
let rendered = render_markdown("- [ ] todo", 80, 0);

assert_eq!(all_line_text(&rendered), vec!["[ ] todo"]);
}

#[test]
fn renders_checked_checklist_without_bullet_prefix() {
let rendered = render_markdown("- [x] done", 80, 0);

assert_eq!(all_line_text(&rendered), vec!["[x] done"]);
}

#[test]
fn wraps_checklist_items_with_aligned_continuation() {
let rendered = render_markdown("- [ ] hello world", 10, 0);

assert_eq!(all_line_text(&rendered), vec!["[ ] hello", " world"]);
}

#[test]
fn keeps_bullets_for_non_task_list_items() {
let rendered = render_markdown("- bullet\n- [x] done\n- [ ] todo", 80, 0);

assert_eq!(
all_line_text(&rendered),
vec!["• bullet", "[x] done", "[ ] todo"]
);
}
}
Loading
Loading