summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorThang Pham <phamducthang1234@gmail.com>2022-01-09 22:02:55 -0500
committerGitHub <noreply@github.com>2022-01-09 22:02:55 -0500
commitb7bb37a22d3a61817d61f171577551b60d19ce2d (patch)
treeafc6c0c946acc9f0da9664b6f0e3ca304980b1ee
parentc873701f345af304c745b66659320315f3578724 (diff)
Render story text in comment view (#62)
-rw-r--r--NOTES.org12
-rw-r--r--hackernews_tui/src/client/mod.rs6
-rw-r--r--hackernews_tui/src/client/parser.rs163
-rw-r--r--hackernews_tui/src/view/comment_view.rs84
4 files changed, 170 insertions, 95 deletions
diff --git a/NOTES.org b/NOTES.org
index 2521484..386a234 100644
--- a/NOTES.org
+++ b/NOTES.org
@@ -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)