summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorThang Pham <phamducthang1234@gmail.com>2021-06-13 23:53:46 +0900
committerGitHub <noreply@github.com>2021-06-13 23:53:46 +0900
commit8ab62e1523f17002e7d0038809db66cdc94fb691 (patch)
treec43d9225d865c0964a990b5ae98bcbe80d5ab528
parentc30be476093babc9c447f049b5788d51c765d28a (diff)
Add comment collapsing (#40)
-rw-r--r--README.md6
-rw-r--r--hackernews_tui/src/hn-tui-default.toml2
-rw-r--r--hackernews_tui/src/keybindings.rs4
-rw-r--r--hackernews_tui/src/view/comment_view.rs208
-rw-r--r--hackernews_tui/src/view/help_view.rs18
-rw-r--r--hackernews_tui/src/view/list_view.rs10
-rw-r--r--hackernews_tui/src/view/text_view.rs8
7 files changed, 207 insertions, 49 deletions
diff --git a/README.md b/README.md
index 0d2c906..2c0c119 100644
--- a/README.md
+++ b/README.md
@@ -181,7 +181,9 @@ In each `View`, press `?` to see a list of supported keyboard shortcuts and thei
- `p`: Focus the previous top level comment
- `l`: Focus the next comment with smaller or equal level
- `h`: Focus the previous comment with smaller or equal level
+- `u`: Focus the parent comment (if exists)
- `r`: Reload the comment view.
+- `tab`: Toggle collapsing the focused comment
- `up`: Scroll up
- `down`: Scroll down
- `page_up`: Scroll up half a page
@@ -197,7 +199,7 @@ In each `View`, press `?` to see a list of supported keyboard shortcuts and thei
In `SearchView`, there are two modes: `Navigation` and `Search`. The default mode is `Search`.
-`Search` mode is similar to Vim's Insert mode, in which users can input the query string.
+`Search` mode is similar to Vim's insert mode, in which users can input the query string.
`Navigation` mode allows the `SearchView` to behave like a `StoryView` with all `StoryView` shortcuts enabled.
@@ -272,7 +274,7 @@ to view the application's log in `log.txt` file.
- [x] make all commands customizable
- [x] add a `View` to read the linked story in reader mode on the terminal. A list of possible suggestion can be found [here](https://news.ycombinator.com/item?id=26930466)
-- [ ] add commands to navigate parent comments and collapse a comment
+- [x] add commands to navigate parent comments and collapse a comment
- [x] make all the configuration options optional
- integrate [HackerNews Official APIs](https://github.com/HackerNews/API) for real-time updating, lazy-loading comments, and sorting stories
- [x] lazy-loading comments
diff --git a/hackernews_tui/src/hn-tui-default.toml b/hackernews_tui/src/hn-tui-default.toml
index 097c156..7e1831a 100644
--- a/hackernews_tui/src/hn-tui-default.toml
+++ b/hackernews_tui/src/hn-tui-default.toml
@@ -130,6 +130,7 @@
# prev_top_level_comment = "p"
# next_leq_level_comment = "l"
# prev_leq_level_comment = "h"
+# parent_comment = "u"
# down = "down"
# up = "up"
# page_down = "page_down"
@@ -138,6 +139,7 @@
# open_link_in_browser = "f"
# open_link_in_article_view = "F"
# reload_comment_view = "r"
+# toggle_collapse_comment = "tab"
#[keymap.article_view_keymap]
# down = "j"
diff --git a/hackernews_tui/src/keybindings.rs b/hackernews_tui/src/keybindings.rs
index 2ca2ed5..8b7ce9d 100644
--- a/hackernews_tui/src/keybindings.rs
+++ b/hackernews_tui/src/keybindings.rs
@@ -152,6 +152,7 @@ pub struct CommentViewKeyMap {
pub prev_top_level_comment: Key,
pub next_leq_level_comment: Key,
pub prev_leq_level_comment: Key,
+ pub parent_comment: Key,
// link opening keymaps
pub open_comment_in_browser: Key,
@@ -164,6 +165,7 @@ pub struct CommentViewKeyMap {
pub page_down: Key,
pub page_up: Key,
+ pub toggle_collapse_comment: Key,
pub reload_comment_view: Key,
}
@@ -176,6 +178,7 @@ impl Default for CommentViewKeyMap {
prev_top_level_comment: Key::new('p'),
next_leq_level_comment: Key::new('l'),
prev_leq_level_comment: Key::new('h'),
+ parent_comment: Key::new('u'),
open_comment_in_browser: Key::new('c'),
open_link_in_browser: Key::new('f'),
@@ -186,6 +189,7 @@ impl Default for CommentViewKeyMap {
page_up: Key::new(event::Key::PageUp),
page_down: Key::new(event::Key::PageDown),
+ toggle_collapse_comment: Key::new(event::Key::Tab),
reload_comment_view: Key::new('r'),
}
}
diff --git a/hackernews_tui/src/view/comment_view.rs b/hackernews_tui/src/view/comment_view.rs
index 7bf5c6f..3818aa2 100644
--- a/hackernews_tui/src/view/comment_view.rs
+++ b/hackernews_tui/src/view/comment_view.rs
@@ -7,11 +7,31 @@ use super::text_view;
use crate::prelude::*;
+type CommentComponent = HideableView<PaddedView<text_view::TextView>>;
+
+#[derive(Debug, Clone)]
+/// CommentState represents the state of a single comment component
+enum CommentState {
+ Collapsed,
+ PartiallyCollapsed,
+ Normal,
+}
+
+impl CommentState {
+ fn visible(&self) -> bool {
+ !matches!(self, Self::Collapsed)
+ }
+}
+
#[derive(Debug, Clone)]
pub struct Comment {
+ state: CommentState,
top_comment_id: u32,
id: u32,
+
text: StyledString,
+ minimized_text: StyledString,
+
height: usize,
links: Vec<String>,
}
@@ -21,13 +41,16 @@ impl Comment {
top_comment_id: u32,
id: u32,
text: StyledString,
+ minimized_text: StyledString,
height: usize,
links: Vec<String>,
) -> Self {
Comment {
+ state: CommentState::Normal,
top_comment_id,
id,
text,
+ minimized_text,
height,
links,
}
@@ -36,8 +59,9 @@ impl Comment {
/// CommentView is a View displaying a list of comments in a HN story
pub struct CommentView {
- story: hn_client::Story,
view: ScrollListView,
+
+ story: hn_client::Story,
comments: Vec<Comment>,
lazy_loading_comments: hn_client::LazyLoadingComments,
@@ -49,7 +73,7 @@ impl ViewWrapper for CommentView {
fn wrap_layout(&mut self, size: Vec2) {
// to support focus the last focused comment on reloading,
- // scroll the the focus element on view initialization
+ // scroll to the focus element during the view initialization
let is_init = self.get_inner().get_scroller().last_available_size() == Vec2::zero();
self.with_view_mut(|v| v.layout(size));
@@ -99,13 +123,13 @@ impl CommentView {
);
comments.iter().for_each(|comment| {
- self.add_item(PaddedView::lrtb(
+ self.add_item(HideableView::new(PaddedView::lrtb(
comment.height * 2,
0,
0,
1,
text_view::TextView::new(comment.text.clone()),
- ));
+ )));
});
self.comments.append(&mut comments);
@@ -219,18 +243,123 @@ impl CommentView {
);
comment_string.append(comment_content);
+ // minimized_comment is used to display collapsed comment
+ let minimized_comment_string = StyledString::styled(
+ format!(
+ "{} {} ago ({} more)",
+ comment.author,
+ get_elapsed_time_as_text(comment.time),
+ subcomments.len() + 1,
+ ),
+ PaletteColor::Secondary,
+ );
+
subcomments.insert(
0,
- Comment::new(top_comment_id, comment.id, comment_string, height, links),
+ Comment::new(
+ top_comment_id,
+ comment.id,
+ comment_string,
+ minimized_comment_string,
+ height,
+ links,
+ ),
);
subcomments
})
.collect()
}
- /// Get the height of each comment in the comment tree
- pub fn get_heights(&self) -> Vec<usize> {
- self.comments.iter().map(|comment| comment.height).collect()
+ /// 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(
+ &self,
+ start_id: usize,
+ max_height: usize,
+ direction: bool,
+ ) -> usize {
+ if direction {
+ // ->
+ (start_id + 1..self.len())
+ .find(|&id| self.comments[id].height <= max_height)
+ .unwrap_or_else(|| self.len())
+ } else {
+ // <-
+ (0..start_id)
+ .rfind(|&id| self.comments[id].height <= max_height)
+ .unwrap_or(start_id)
+ }
+ }
+
+ /// Return the id of the next visible comment (`direction` dependent and starting but not including `start_id`)
+ pub fn find_next_visible_comment(&self, start_id: usize, direction: bool) -> usize {
+ if direction {
+ // ->
+ (start_id + 1..self.len())
+ .find(|&id| self.comments[id].state.visible())
+ .unwrap_or_else(|| self.len())
+ } else {
+ // <-
+ (0..start_id)
+ .rfind(|&id| self.comments[id].state.visible())
+ .unwrap_or(start_id)
+ }
+ }
+
+ fn get_comment_component_mut(&mut self, id: usize) -> &mut CommentComponent {
+ self.get_item_mut(id)
+ .unwrap()
+ .downcast_mut::<CommentComponent>()
+ .unwrap()
+ }
+
+ /// Toggle the collapsing state of children of `parent_comment_id` comment.
+ /// **Note**: partially collapsed comment's state is unchanged.
+ fn toggle_collapse_child_comments(&mut self, parent_comment_id: usize) {
+ let parent_height = self.comments[parent_comment_id].height;
+ let end = self.find_comment_id_by_max_height(parent_comment_id, parent_height, true);
+ (parent_comment_id + 1..end).for_each(|i| {
+ match self.comments[i].state {
+ CommentState::Collapsed => {
+ self.comments[i].state = CommentState::Normal;
+ self.get_comment_component_mut(i).unhide();
+ }
+ CommentState::Normal => {
+ self.comments[i].state = CommentState::Collapsed;
+ self.get_comment_component_mut(i).hide();
+ }
+ CommentState::PartiallyCollapsed => {} // for partially collapsed comment, keep the state unchanged
+ }
+ });
+ }
+
+ /// Toggle the collapsing state of currently focused comment and its children
+ pub fn toggle_collapse_focused_comment(&mut self) {
+ let id = self.get_focus_index();
+ let comment = self.comments[id].clone();
+ match comment.state {
+ CommentState::Collapsed => {
+ panic!(
+ "invalid comment state `Collapsed` when calling `toggle_collapse_focused_comment`"
+ );
+ }
+ CommentState::PartiallyCollapsed => {
+ self.get_comment_component_mut(id)
+ .get_inner_mut()
+ .get_inner_mut()
+ .set_content(comment.text);
+ self.toggle_collapse_child_comments(id);
+ self.comments[id].state = CommentState::Normal;
+ }
+ CommentState::Normal => {
+ self.get_comment_component_mut(id)
+ .get_inner_mut()
+ .get_inner_mut()
+ .set_content(comment.minimized_text);
+ self.toggle_collapse_child_comments(id);
+ self.comments[id].state = CommentState::PartiallyCollapsed;
+ }
+ };
}
inner_getters!(self.view: ScrollListView);
@@ -287,62 +416,50 @@ fn get_comment_main_view(
})
// comment navigation shortcuts
.on_pre_event_inner(comment_view_keymap.prev_comment, |s, _| {
- let id = s.get_focus_index();
- if id == 0 {
- None
- } else {
- s.set_focus_index(id - 1)
- }
+ s.set_focus_index(s.find_next_visible_comment(s.get_focus_index(), false))
})
.on_pre_event_inner(comment_view_keymap.next_comment, |s, _| {
- let id = s.get_focus_index();
- if id + 1 == s.len() {
+ let next_id = s.find_next_visible_comment(s.get_focus_index(), true);
+ if next_id == s.len() {
s.load_comments();
}
- s.set_focus_index(id + 1)
+ s.set_focus_index(next_id)
})
.on_pre_event_inner(comment_view_keymap.next_leq_level_comment, move |s, _| {
- let heights = s.get_heights();
let id = s.get_focus_index();
- let (_, right) = heights.split_at(id + 1);
- let offset = right.iter().position(|&h| h <= heights[id]);
- let next_id = match offset {
- None => s.len(),
- Some(offset) => id + offset + 1,
- };
+ let next_id = s.find_comment_id_by_max_height(id, s.comments[id].height, true);
if next_id == s.len() {
s.load_comments();
}
s.set_focus_index(next_id)
})
.on_pre_event_inner(comment_view_keymap.prev_leq_level_comment, move |s, _| {
- let heights = s.get_heights();
let id = s.get_focus_index();
- let (left, _) = heights.split_at(id);
- let next_id = left.iter().rposition(|&h| h <= heights[id]).unwrap_or(id);
+ let next_id = s.find_comment_id_by_max_height(id, s.comments[id].height, false);
s.set_focus_index(next_id)
})
.on_pre_event_inner(comment_view_keymap.next_top_level_comment, move |s, _| {
- let heights = s.get_heights();
let id = s.get_focus_index();
- let (_, right) = heights.split_at(id + 1);
- let offset = right.iter().position(|&h| h == 0);
- let next_id = match offset {
- None => s.len(),
- Some(offset) => id + offset + 1,
- };
+ let next_id = s.find_comment_id_by_max_height(id, 0, true);
if next_id == s.len() {
s.load_comments();
}
s.set_focus_index(next_id)
})
.on_pre_event_inner(comment_view_keymap.prev_top_level_comment, move |s, _| {
- let heights = s.get_heights();
let id = s.get_focus_index();
- let (left, _) = heights.split_at(id);
- let next_id = left.iter().rposition(|&h| h == 0).unwrap_or(id);
+ let next_id = s.find_comment_id_by_max_height(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);
+ s.set_focus_index(next_id)
+ } else {
+ Some(EventResult::Consumed(None))
+ }
+ })
// open external link shortcuts
.on_pre_event_inner(comment_view_keymap.open_link_in_browser, |s, _| {
match s.raw_command.parse::<usize>() {
@@ -377,6 +494,17 @@ fn get_comment_main_view(
Err(_) => None,
},
)
+ .on_pre_event_inner(comment_view_keymap.open_comment_in_browser, move |s, _| {
+ let id = s.comments[s.get_focus_index()].id;
+ let url = format!("{}/item?id={}", hn_client::HN_HOST_URL, id);
+ open_url_in_browser(&url);
+ Some(EventResult::Consumed(None))
+ })
+ // other commands
+ .on_pre_event_inner(comment_view_keymap.toggle_collapse_comment, move |s, _| {
+ s.toggle_collapse_focused_comment();
+ Some(EventResult::Consumed(None))
+ })
.on_pre_event_inner(comment_view_keymap.reload_comment_view, move |s, _| {
let comment = &s.comments[s.get_focus_index()];
let focus_id = (comment.top_comment_id, comment.id);
@@ -385,12 +513,6 @@ fn get_comment_main_view(
move |s| add_comment_view_layer(s, client, &story, focus_id, true)
}))
})
- .on_pre_event_inner(comment_view_keymap.open_comment_in_browser, move |s, _| {
- let id = s.comments[s.get_focus_index()].id;
- let url = format!("{}/item?id={}", hn_client::HN_HOST_URL, id);
- open_url_in_browser(&url);
- Some(EventResult::Consumed(None))
- })
.full_height()
}
diff --git a/hackernews_tui/src/view/help_view.rs b/hackernews_tui/src/view/help_view.rs
index 4f280c6..8987e69 100644
--- a/hackernews_tui/src/view/help_view.rs
+++ b/hackernews_tui/src/view/help_view.rs
@@ -288,6 +288,10 @@ impl HasHelpView for CommentView {
comment_view_keymap.prev_leq_level_comment.to_string(),
"Focus the previous comment at smaller or equal level",
),
+ (
+ comment_view_keymap.parent_comment.to_string(),
+ "Focus the parent comment (if exists)",
+ ),
],
),
(
@@ -338,10 +342,16 @@ impl HasHelpView for CommentView {
],
),
view_navigation_key_shortcuts!(),
- other_key_shortcuts!((
- comment_view_keymap.reload_comment_view.to_string(),
- "Reload the comment view"
- )),
+ other_key_shortcuts!(
+ (
+ comment_view_keymap.reload_comment_view.to_string(),
+ "Reload the comment view"
+ ),
+ (
+ comment_view_keymap.toggle_collapse_comment.to_string(),
+ "Toggle collapsing the focused comment"
+ )
+ ),
])
}
}
diff --git a/hackernews_tui/src/view/list_view.rs b/hackernews_tui/src/view/list_view.rs
index a55b173..056bd1a 100644
--- a/hackernews_tui/src/view/list_view.rs
+++ b/hackernews_tui/src/view/list_view.rs
@@ -9,6 +9,8 @@ pub trait ScrollableList {
fn get_focus_index(&self) -> usize;
fn set_focus_index(&mut self, id: usize) -> Option<EventResult>;
fn add_item<V: IntoBoxedView + 'static>(&mut self, view: V);
+ fn get_item(&self, id: usize) -> Option<&(dyn View + 'static)>;
+ fn get_item_mut(&mut self, id: usize) -> Option<&mut (dyn View + 'static)>;
fn get_scroller(&self) -> &scroll::Core;
fn get_scroller_mut(&mut self) -> &mut scroll::Core;
// Move the scroller to the focused area and adjust the scroller
@@ -97,6 +99,14 @@ macro_rules! impl_scrollable_list {
fn get_scroller(&self) -> &scroll::Core {
self.get_inner().get_scroller()
}
+
+ fn get_item(&self, id: usize) -> Option<&(dyn View + 'static)> {
+ self.get_inner().get_inner().get_child(id)
+ }
+
+ fn get_item_mut(&mut self, id: usize) -> Option<&mut (dyn View + 'static)> {
+ self.get_inner_mut().get_inner_mut().get_child_mut(id)
+ }
};
}
diff --git a/hackernews_tui/src/view/text_view.rs b/hackernews_tui/src/view/text_view.rs
index 9591edf..7fa19c2 100644
--- a/hackernews_tui/src/view/text_view.rs
+++ b/hackernews_tui/src/view/text_view.rs
@@ -23,6 +23,14 @@ impl TextView {
}
}
+ pub fn set_content<S>(&mut self, content: S)
+ where
+ S: Into<utils::markup::StyledString>,
+ {
+ self.content = content.into();
+ self.size_cache = None;
+ }
+
fn is_size_cache_valid(&self, size: Vec2) -> bool {
match self.size_cache {
None => false,