diff options
author | Thang Pham <phamducthang1234@gmail.com> | 2022-01-09 22:02:55 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-01-09 22:02:55 -0500 |
commit | b7bb37a22d3a61817d61f171577551b60d19ce2d (patch) | |
tree | afc6c0c946acc9f0da9664b6f0e3ca304980b1ee | |
parent | c873701f345af304c745b66659320315f3578724 (diff) |
Render story text in comment view (#62)
-rw-r--r-- | NOTES.org | 12 | ||||
-rw-r--r-- | hackernews_tui/src/client/mod.rs | 6 | ||||
-rw-r--r-- | hackernews_tui/src/client/parser.rs | 163 | ||||
-rw-r--r-- | hackernews_tui/src/view/comment_view.rs | 84 |
4 files changed, 170 insertions, 95 deletions
@@ -3,7 +3,7 @@ * Roadmap -** ONGOING Render story/comment main text in ~CommentView~ +** DONE Render story/comment main text in ~CommentView~ ** TODO Improve error message for config parser @@ -25,5 +25,13 @@ More specifically, open link starting with ~https://news.ycombinator.com/item?id * Changes -** [[https://github.com/aome510/hackernews-TUI/pull/62][Render story text in comment view #62]] +** [[https://github.com/aome510/hackernews-TUI/pull/62][Render story text in comment view #62]] :ATTACH: +:PROPERTIES: +:ID: 024b344d-e592-45d0-8957-0477a4f95139 +:END: This PR implements [[*Render story/comment main text in ~CommentView~]]. + +- Render a story text on the top of the ~CommentView~: + + #+attr_html: :width 1000 + [[attachment:_20220109_200339Screen Shot 2022-01-09 at 8.03.36 PM.png]] diff --git a/hackernews_tui/src/client/mod.rs b/hackernews_tui/src/client/mod.rs index 8c8d896..bb1a21b 100644 --- a/hackernews_tui/src/client/mod.rs +++ b/hackernews_tui/src/client/mod.rs @@ -3,7 +3,7 @@ mod parser; mod query; // re-export -pub use parser::{Article, Comment, CommentState, Story}; +pub use parser::{Article, CollapseState, HnText, Story}; pub use query::StoryNumericFilters; use crate::prelude::*; @@ -20,8 +20,8 @@ pub const SEARCH_LIMIT: usize = 15; static CLIENT: once_cell::sync::OnceCell<HNClient> = once_cell::sync::OnceCell::new(); -pub type CommentSender = crossbeam_channel::Sender<Vec<Comment>>; -pub type CommentReceiver = crossbeam_channel::Receiver<Vec<Comment>>; +pub type CommentSender = crossbeam_channel::Sender<Vec<HnText>>; +pub type CommentReceiver = crossbeam_channel::Receiver<Vec<HnText>>; /// HNClient is a HTTP client to communicate with Hacker News APIs. #[derive(Clone)] diff --git a/hackernews_tui/src/client/parser.rs b/hackernews_tui/src/client/parser.rs index c3739af..bb879be 100644 --- a/hackernews_tui/src/client/parser.rs +++ b/hackernews_tui/src/client/parser.rs @@ -15,9 +15,9 @@ lazy_static! { /// a regex that matches whitespace character(s) static ref WS_RE: Regex = Regex::new(r"\s+").unwrap(); - /// a regex used to parse a HN comment (in HTML format) + /// a regex used to parse a HN text (in HTML format) /// It consists of multiple regex(s) representing different elements - static ref COMMENT_RE: Regex = Regex::new(&format!( + static ref HN_TEXT_RE: Regex = Regex::new(&format!( "(({})|({})|({})|({})|({})|({}))", // a regex that matches a HTML paragraph r"<p>(?s)(?P<paragraph>(|[^>].*?))</p>", @@ -76,6 +76,8 @@ pub struct StoryResponse { author: Option<String>, url: Option<String>, + #[serde(rename(deserialize = "story_text"))] + text: Option<String>, #[serde(default)] #[serde(deserialize_with = "parse_null_default")] @@ -122,39 +124,40 @@ pub struct StoriesResponse { // parsed structs -/// Story represents a parsed Hacker News story +/// A parsed Hacker News story #[derive(Debug, Clone)] pub struct Story { pub id: u32, pub title: StyledString, pub url: String, pub author: String, + pub text: HnText, pub points: u32, pub num_comments: usize, pub time: u64, } -/// Comment represents a parsed Hacker News comment +/// A parsed Hacker News text #[derive(Debug, Clone)] -pub struct Comment { +pub struct HnText { pub id: u32, - pub height: usize, - pub state: CommentState, + pub level: usize, + pub state: CollapseState, pub text: StyledString, pub minimized_text: StyledString, pub links: Vec<String>, } #[derive(Debug, Clone)] -/// CommentState represents the state of a single comment component -pub enum CommentState { +/// The collapse state of a text component +pub enum CollapseState { Collapsed, PartiallyCollapsed, Normal, } #[derive(Debug, Clone, Deserialize)] -/// Article represents a web article in a reader mode +/// A web article in a reader mode pub struct Article { pub title: String, pub url: String, @@ -238,56 +241,120 @@ impl From<StoryResponse> for Story { parsed_title.append_plain(&title[curr_pos..title.len()]); } + let author = s.author.unwrap_or_else(|| String::from("[deleted]")); + + // parse story's text + let text = { + let metadata = utils::combine_styled_strings(vec![ + StyledString::plain(parsed_title.source()), + StyledString::plain("\n"), + StyledString::styled( + format!( + "{} points | by {} | {} ago | {} comments\n", + s.points, + author, + utils::get_elapsed_time_as_text(s.time), + s.num_comments, + ), + config::get_config_theme().component_style.metadata, + ), + ]); + + // the HTML story text returned by HN Algolia API doesn't wrap a + // paragraph inside a `<p><\p>` tag pair. + // Instead, it seems to use `<p>` to represent a paragraph break. + let mut story_text = decode_html(&s.text.unwrap_or_default()).replace("<p>", "\n\n"); + + let minimized_text = if story_text.is_empty() { + metadata.clone() + } else { + story_text = format!("\n{}", story_text); + + utils::combine_styled_strings(vec![ + metadata.clone(), + StyledString::plain("... (more)"), + ]) + }; + + let mut text = metadata; + let mut links = vec![]; + parse_hn_html_text(story_text, Style::default(), &mut text, &mut links); + + HnText { + id: s.id, + level: 0, + state: CollapseState::Normal, + minimized_text, + text, + links, + } + }; + Story { title: parsed_title, url: s.url.unwrap_or_default(), - author: s.author.unwrap_or_else(|| String::from("[deleted]")), + author, id: s.id, points: s.points, num_comments: s.num_comments, time: s.time, + text, } } } -impl From<CommentResponse> for Vec<Comment> { +impl From<CommentResponse> for Vec<HnText> { fn from(c: CommentResponse) -> Self { + // recursively parse child comments of the current comment let mut children = c .children .into_par_iter() .filter(|comment| comment.author.is_some() && comment.text.is_some()) - .flat_map(<Vec<Comment>>::from) + .flat_map(<Vec<HnText>>::from) .map(|mut c| { - c.height += 1; // update the height of every children comments + c.level += 1; // update the level of every children comments c }) .collect::<Vec<_>>(); - let metadata = utils::combine_styled_strings(vec![ - StyledString::styled( - c.author.unwrap_or_default(), - config::get_config_theme().component_style.username, - ), - StyledString::styled( - format!(" {} ago ", utils::get_elapsed_time_as_text(c.time)), - config::get_config_theme().component_style.metadata, - ), - ]); - let (text, links) = parse_comment(&c.text.unwrap_or_default(), metadata.clone()); - - let comment = Comment { - id: c.id, - height: 0, - state: CommentState::Normal, - text, - minimized_text: utils::combine_styled_strings(vec![ - metadata, + // parse current comment + let comment = { + let metadata = utils::combine_styled_strings(vec![ + StyledString::styled( + c.author.unwrap_or_default(), + config::get_config_theme().component_style.username, + ), StyledString::styled( - format!("({} more)", children.len() + 1), + format!(" {} ago ", utils::get_elapsed_time_as_text(c.time)), config::get_config_theme().component_style.metadata, ), - ]), - links, + ]); + + let mut text = + utils::combine_styled_strings(vec![metadata.clone(), StyledString::plain("\n")]); + let mut links = vec![]; + + parse_hn_html_text( + decode_html(&c.text.unwrap_or_default()), + Style::default(), + &mut text, + &mut links, + ); + + HnText { + id: c.id, + level: 0, + state: CollapseState::Normal, + minimized_text: utils::combine_styled_strings(vec![ + metadata, + StyledString::styled( + format!("({} more)", children.len() + 1), + config::get_config_theme().component_style.metadata, + ), + ]), + text, + links, + } }; let mut comments = vec![comment]; @@ -620,21 +687,9 @@ fn decode_html(s: &str) -> String { html_escape::decode_html_entities(s).into() } -/// Parse a HTML comment into a styled text. -/// The fucntion also returns a list of links inside the comment beside the parsed styled text. -fn parse_comment(text: &str, metadata: StyledString) -> (StyledString, Vec<String>) { - let text = decode_html(text); - - let mut s = utils::combine_styled_strings(vec![metadata, StyledString::plain("\n")]); - let mut links = vec![]; - parse_comment_helper(text, Style::default(), &mut s, &mut links); - - (s, links) -} - -/// A helper function for parsing comment text that allows recursively parsing sub-elements of the text. -fn parse_comment_helper(text: String, style: Style, s: &mut StyledString, links: &mut Vec<String>) { - debug!("parse comment: {}", text); +/// parse a Hacker News HTML text +fn parse_hn_html_text(text: String, style: Style, s: &mut StyledString, links: &mut Vec<String>) { + debug!("parse hn html text: {}", text); let mut curr_pos = 0; @@ -642,7 +697,7 @@ fn parse_comment_helper(text: String, style: Style, s: &mut StyledString, links: // It is used to add a break between 2 consecutive paragraphs. let mut seen_first_paragraph = false; - for caps in COMMENT_RE.captures_iter(&text) { + for caps in HN_TEXT_RE.captures_iter(&text) { // the part that doesn't match any patterns is rendered in the default style let whole_match = caps.get(0).unwrap(); if curr_pos < whole_match.start() { @@ -666,7 +721,7 @@ fn parse_comment_helper(text: String, style: Style, s: &mut StyledString, links: .repeat(m_quote.as_str().matches('>').count()), style, ); - parse_comment_helper( + parse_hn_html_text( m_text.as_str().to_string(), component_style.quote.into(), s, @@ -681,7 +736,7 @@ fn parse_comment_helper(text: String, style: Style, s: &mut StyledString, links: seen_first_paragraph = true; } - parse_comment_helper(m.as_str().to_string(), style, s, links); + parse_hn_html_text(m.as_str().to_string(), style, s, links); s.append_plain("\n"); } else if let Some(m) = caps.name("link") { diff --git a/hackernews_tui/src/view/comment_view.rs b/hackernews_tui/src/view/comment_view.rs index a8f7381..7d622b8 100644 --- a/hackernews_tui/src/view/comment_view.rs +++ b/hackernews_tui/src/view/comment_view.rs @@ -11,7 +11,7 @@ type CommentComponent = HideableView<PaddedView<text_view::TextView>>; /// CommentView is a View displaying a list of comments in a HN story pub struct CommentView { view: ScrollListView, - comments: Vec<client::Comment>, + comments: Vec<client::HnText>, receiver: client::CommentReceiver, raw_command: String, @@ -23,13 +23,22 @@ impl ViewWrapper for CommentView { impl CommentView { /// Return a new CommentView given a comment list and the discussed story url - pub fn new(receiver: client::CommentReceiver) -> Self { + pub fn new(main_text: client::HnText, receiver: client::CommentReceiver) -> Self { let mut view = CommentView { - comments: vec![], - view: LinearLayout::vertical().scrollable(), + view: LinearLayout::vertical() + .child(HideableView::new(PaddedView::lrtb( + main_text.level * 2 + 1, + 1, + 0, + 1, + text_view::TextView::new(main_text.text.clone()), + ))) + .scrollable(), + comments: vec![main_text], raw_command: String::new(), receiver, }; + view.try_update_comments(); view } @@ -54,15 +63,15 @@ impl CommentView { new_comments.iter().for_each(|comment| { let text_view = text_view::TextView::new(comment.text.clone()); self.add_item(HideableView::new(PaddedView::lrtb( - comment.height * 2 + 1, + comment.level * 2 + 1, 1, 0, 1, - if comment.height > 0 { + if comment.level > 0 { // get the padding style (color) based on the comment's height // // We use base 16 colors to display the comment's padding - let c = config::Color::from((comment.height % 16) as u8); + let c = config::Color::from((comment.level % 16) as u8); text_view .padding(TextPadding::default().left(StyledPaddingChar::new('▎', c.into()))) } else { @@ -78,23 +87,23 @@ impl CommentView { self.layout(self.get_scroller().last_outer_size()) } - /// Return the `id` of the first (`direction` dependent and starting but not including `start_id`) - /// comment which has the `height` less than or equal the `max_height` - pub fn find_comment_id_by_max_height( + /// Return the id of the first comment (`direction` dependent) + /// whose level is less than or equal `max_level`. + pub fn find_comment_id_by_max_level( &self, start_id: usize, - max_height: usize, + max_level: usize, direction: bool, ) -> usize { if direction { // -> (start_id + 1..self.len()) - .find(|&id| self.comments[id].height <= max_height) + .find(|&id| self.comments[id].level <= max_level) .unwrap_or_else(|| self.len()) } else { // <- (0..start_id) - .rfind(|&id| self.comments[id].height <= max_height) + .rfind(|&id| self.comments[id].level <= max_level) .unwrap_or(start_id) } } @@ -133,21 +142,21 @@ impl CommentView { /// **Note** `PartiallyCollapsed` comment's state is unchanged, only toggle its visibility. /// Also, the state and visibility of such comment's children are unaffected. fn toggle_comment_collapse_state(&mut self, i: usize, min_height: usize) { - if i == self.len() || self.comments[i].height <= min_height { + if i == self.len() || self.comments[i].level <= min_height { return; } match self.comments[i].state { - client::CommentState::Collapsed => { - self.comments[i].state = client::CommentState::Normal; + client::CollapseState::Collapsed => { + self.comments[i].state = client::CollapseState::Normal; self.get_comment_component_mut(i).unhide(); self.toggle_comment_collapse_state(i + 1, min_height) } - client::CommentState::Normal => { - self.comments[i].state = client::CommentState::Collapsed; + client::CollapseState::Normal => { + self.comments[i].state = client::CollapseState::Collapsed; self.get_comment_component_mut(i).hide(); self.toggle_comment_collapse_state(i + 1, min_height) } - client::CommentState::PartiallyCollapsed => { + client::CollapseState::PartiallyCollapsed => { let component = self.get_comment_component_mut(i); if component.is_visible() { component.hide(); @@ -156,7 +165,7 @@ impl CommentView { } // skip toggling all child comments of the current comment - let next_id = self.find_comment_id_by_max_height(i, self.comments[i].height, true); + let next_id = self.find_comment_id_by_max_level(i, self.comments[i].level, true); self.toggle_comment_collapse_state(next_id, min_height) } }; @@ -167,26 +176,26 @@ impl CommentView { let id = self.get_focus_index(); let comment = self.comments[id].clone(); match comment.state { - client::CommentState::Collapsed => { + client::CollapseState::Collapsed => { panic!( "invalid comment state `Collapsed` when calling `toggle_collapse_focused_comment`" ); } - client::CommentState::PartiallyCollapsed => { + client::CollapseState::PartiallyCollapsed => { self.get_comment_component_mut(id) .get_inner_mut() .get_inner_mut() .set_content(comment.text); - self.toggle_comment_collapse_state(id + 1, self.comments[id].height); - self.comments[id].state = client::CommentState::Normal; + self.toggle_comment_collapse_state(id + 1, self.comments[id].level); + self.comments[id].state = client::CollapseState::Normal; } - client::CommentState::Normal => { + client::CollapseState::Normal => { self.get_comment_component_mut(id) .get_inner_mut() .get_inner_mut() .set_content(comment.minimized_text); - self.toggle_comment_collapse_state(id + 1, self.comments[id].height); - self.comments[id].state = client::CommentState::PartiallyCollapsed; + self.toggle_comment_collapse_state(id + 1, self.comments[id].level); + self.comments[id].state = client::CollapseState::PartiallyCollapsed; } }; } @@ -196,7 +205,10 @@ impl CommentView { /// Return a main view of a CommentView displaying the comment list. /// The main view of a CommentView is a View without status bar or footer. -fn get_comment_main_view(receiver: client::CommentReceiver) -> impl View { +fn get_comment_main_view( + main_text: client::HnText, + receiver: client::CommentReceiver, +) -> impl View { let comment_view_keymap = config::get_comment_view_keymap().clone(); let is_suffix_key = |c: &Event| -> bool { @@ -205,7 +217,7 @@ fn get_comment_main_view(receiver: client::CommentReceiver) -> impl View { || *c == comment_view_keymap.open_link_in_article_view.into() }; - OnEventView::new(CommentView::new(receiver)) + OnEventView::new(CommentView::new(main_text, receiver)) .on_pre_event_inner(EventTrigger::from_fn(|_| true), move |s, e| { s.try_update_comments(); @@ -250,28 +262,28 @@ fn get_comment_main_view(receiver: client::CommentReceiver) -> impl View { }) .on_pre_event_inner(comment_view_keymap.next_leq_level_comment, move |s, _| { let id = s.get_focus_index(); - let next_id = s.find_comment_id_by_max_height(id, s.comments[id].height, true); + let next_id = s.find_comment_id_by_max_level(id, s.comments[id].level, true); s.set_focus_index(next_id) }) .on_pre_event_inner(comment_view_keymap.prev_leq_level_comment, move |s, _| { let id = s.get_focus_index(); - let next_id = s.find_comment_id_by_max_height(id, s.comments[id].height, false); + let next_id = s.find_comment_id_by_max_level(id, s.comments[id].level, false); s.set_focus_index(next_id) }) .on_pre_event_inner(comment_view_keymap.next_top_level_comment, move |s, _| { let id = s.get_focus_index(); - let next_id = s.find_comment_id_by_max_height(id, 0, true); + let next_id = s.find_comment_id_by_max_level(id, 0, true); s.set_focus_index(next_id) }) .on_pre_event_inner(comment_view_keymap.prev_top_level_comment, move |s, _| { let id = s.get_focus_index(); - let next_id = s.find_comment_id_by_max_height(id, 0, false); + let next_id = s.find_comment_id_by_max_level(id, 0, false); s.set_focus_index(next_id) }) .on_pre_event_inner(comment_view_keymap.parent_comment, move |s, _| { let id = s.get_focus_index(); - if s.comments[id].height > 0 { - let next_id = s.find_comment_id_by_max_height(id, s.comments[id].height - 1, false); + if s.comments[id].level > 0 { + let next_id = s.find_comment_id_by_max_level(id, s.comments[id].level - 1, false); s.set_focus_index(next_id) } else { Some(EventResult::Consumed(None)) @@ -330,7 +342,7 @@ pub fn get_comment_view(story: &client::Story, receiver: client::CommentReceiver let status_bar = utils::construct_view_title_bar(&format!("Comment View - {}", story.title.source())); - let main_view = get_comment_main_view(receiver); + let main_view = get_comment_main_view(story.text.clone(), receiver); let mut view = LinearLayout::vertical() .child(status_bar) |