diff options
author | Sam Tay <sam.chong.tay@gmail.com> | 2020-06-14 22:41:13 -0700 |
---|---|---|
committer | Sam Tay <sam.chong.tay@gmail.com> | 2020-06-14 22:41:13 -0700 |
commit | 812cfc8c3e3b878ae11b4c79d468e718ad583689 (patch) | |
tree | 44a8317ef8dd7ea903071c4b4ef3683ebe0898e4 | |
parent | 9049d2d09116d544ec4c35a41b769a7296bfecc3 (diff) |
Refactor: split up tui::app::run
-rw-r--r-- | TODO.md | 3 | ||||
-rw-r--r-- | src/tui/app.rs | 218 | ||||
-rw-r--r-- | src/tui/mod.rs | 1 | ||||
-rw-r--r-- | src/tui/views.rs | 248 |
4 files changed, 331 insertions, 139 deletions
@@ -9,9 +9,6 @@ changing [soon](https://meta.stackexchange.com/q/348746). ### v0.2.0 #### Cursive interface for viewing questions and answers -0. Split up `run` function with many little helper functions defining callbacks - and stull general to both the question and answer panes -1. Make each pane scrollable 2. Handle focus with tab and h,l 4. Allow cycling layouts 5. Init with smaller layout if terminal size smaller diff --git a/src/tui/app.rs b/src/tui/app.rs index 62a3db9..35e3ef1 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,18 +1,17 @@ -use cursive::event::EventResult; use cursive::theme::{BaseColor, Color, Effect, Style}; -use cursive::traits::{Nameable, Resizable, Scrollable}; +use cursive::utils::markup::StyledString; use cursive::utils::span::SpannedString; -use cursive::view::Margins; -use cursive::views::{ - LinearLayout, NamedView, OnEventView, PaddedView, Panel, ResizedView, ScrollView, SelectView, - TextContent, TextView, -}; +use cursive::Cursive; use cursive::XY; use std::cmp; use std::collections::HashMap; use std::sync::Arc; use super::markdown; +use super::views::{ + FullLayout, ListView, MdView, Name, NAME_ANSWER_LIST, NAME_ANSWER_VIEW, NAME_QUESTION_LIST, + NAME_QUESTION_VIEW, +}; use crate::config; use crate::error::Result; use crate::stackexchange::{Answer, Question}; @@ -31,29 +30,6 @@ use crate::stackexchange::{Answer, Question}; // TODO Circular Focus handles layout & focus & stuff // TODO these might be "layers" ? -pub enum Layout { - BothColumns, - SingleColumn, - FullScreen, -} - -// Tab to cycle focus -// TODO use NamedView -pub enum Focus { - QuestionList, - AnswerList, - Question, - Answer, -} - -pub enum Mode { - /// Akin to vim, keys are treated as commands - Normal, - /// Akin to vim, user is typing in bottom prompt - Insert, - // TODO if adding a search feature, that will be anther mode -} - // TODO make my own views for lists, md, etc, and use cursive::inner_getters! // (or at least type synonyms) // and abstract out the common builder funcs @@ -72,13 +48,11 @@ pub enum Mode { //} // TODO maybe a struct like Tui::new(stackexchange) creates App::new and impls tui.run()? -// TODO views::SelectView? // TODO take async questions // TODO take the entire SE struct for future questions pub fn run(qs: Vec<Question>) -> Result<()> { let mut siv = cursive::default(); siv.load_theme_file(config::theme_file_name()?).unwrap(); // TODO dont unwrap - let XY { x, y } = siv.screen_size(); //app state //put this in siv.set_user_data? hmm @@ -94,124 +68,96 @@ pub fn run(qs: Vec<Question>) -> Result<()> { .collect(); let answer_map = Arc::new(answer_map); - // question view - let current_question = TextContent::new(""); // init would be great - let question_view: NamedView<TextView> = - TextView::new_with_content(current_question.clone()).with_name("question"); + let question_view = MdView::new(Name::QuestionView); + let answer_view = MdView::new(Name::AnswerView); - // answer view - let current_answer = TextContent::new(""); // init would be great - let answer_view: NamedView<TextView> = - TextView::new_with_content(current_answer.clone()).with_name("answer"); + let question_list_view = ListView::new_with_items( + Name::QuestionList, + qs.into_iter().map(|q| (preview_question(&q), q.id)), + move |s, qid| question_selected_callback(question_map.clone(), s, qid), + ); - // question list view - //let question_map_ = question_map.clone(); - //let current_question_ = current_question.clone(); - // TODO fuck select view has indexing capabilities :facepalm: - let mut question_list_view: NamedView<SelectView<u32>> = SelectView::new() - .with_all(qs.into_iter().map(|q| (q.title, q.id))) - .on_select(move |mut s, qid| { - let q = question_map.get(qid).unwrap(); - let XY { x, y: _y } = s.screen_size(); - current_question.set_content(markdown::parse(&q.body)); - let cb = s.call_on_name("answer_list", |v: &mut SelectView<u32>| { - v.clear(); - v.add_all(q.answers.iter().map(|a| { - // TODO make a damn func for this - // add score & accepted checkmark - let width = cmp::min(a.body.len(), x / 2); - let a_body = a.body[..width].to_owned(); - let md = markdown::preview(x, a_body); - let color = if a.score > 0 { - Color::Light(BaseColor::Green) - } else { - Color::Light(BaseColor::Red) - }; - let mut preview = SpannedString::styled( - format!("({}) ", a.score), - Style::merge(&[Style::from(color), Style::from(Effect::Bold)]), - ); - if a.is_accepted { - preview.append_styled( - "\u{2713} ", // "✔ " - Style::merge(&[ - Style::from(Color::Light(BaseColor::Green)), - Style::from(Effect::Bold), - ]), - ); - } - preview.append(md); - (preview, a.id) - })); - v.set_selection(0) - }); - if let Some(cb) = cb { - cb(&mut s) - } - }) - .with_name("question_list"); - let question_list_view = make_select_scrollable(question_list_view); - let question_list_view = Panel::new(question_list_view).title("Questions"); - - // answer list view - //let answer_map_ = answer_map.clone(); - //let current_answer_ = current_question.clone(); - let answer_list_view: NamedView<SelectView<u32>> = SelectView::new() - .on_select(move |_, aid| { - let a = answer_map.get(aid).unwrap(); - current_answer.set_content(markdown::parse(&a.body)); - }) - .with_name("answer_list"); - let answer_list_view = make_select_scrollable(answer_list_view); - let answer_list_view = Panel::new(answer_list_view).title("Answers"); + // TODO init with qs[0].answers ? + let answer_list_view = ListView::new(Name::AnswerList, move |s, aid| { + let a = answer_map.get(aid).unwrap(); + s.call_on_name(NAME_ANSWER_VIEW, |v: &mut MdView| v.set_content(&a.body)); + }); - //TODO eventually do this in the right place, e.g. abstract out md - //parser, write benches, & do within threads - let margin = 1; - let x = if x % 2 == 0 { x - 1 } else { x }; - siv.add_layer(PaddedView::new( - Margins::lrtb(margin, margin, 0, 0), - LinearLayout::horizontal() - .child(ResizedView::with_fixed_width( - (x - 2 * margin) / 2, - LinearLayout::vertical() - .child(question_list_view.scrollable().fixed_height(y / 3)) - .child(Panel::new(question_view.scrollable()).fixed_height(2 * y / 3)), - )) - .child(ResizedView::with_fixed_width( - (x - 2 * margin) / 2, - LinearLayout::vertical() - .child(answer_list_view.scrollable().fixed_height(y / 3)) - .child(Panel::new(answer_view.scrollable()).fixed_height(2 * y / 3)), - )), + siv.add_layer(FullLayout::new( + 1, + siv.screen_size(), + question_list_view, + question_view, + answer_list_view, + answer_view, )); - let cb = siv.call_on_name("question_list", |v: &mut SelectView<u32>| { - v.set_selection(0) - }); + + let cb = siv.call_on_name(NAME_QUESTION_LIST, |v: &mut ListView| v.select(0)); if let Some(cb) = cb { cb(&mut siv) } cursive::logger::init(); siv.add_global_callback('?', cursive::Cursive::toggle_debug_console); + println!("{:?}", siv.debug_name(NAME_QUESTION_VIEW)); siv.run(); Ok(()) } -// TODO move this out to utils -// use LastSizeView if i want to resize things with shift <HJKL> -// Also, it might be that we control all scrolling from the top -fn make_select_scrollable( - view: NamedView<SelectView<u32>>, -) -> ScrollView<OnEventView<NamedView<SelectView<u32>>>> { - // Clobber existing functionality: - OnEventView::new(view) - .on_pre_event_inner('k', |s, _| { - Some(EventResult::Consumed(Some(s.get_mut().select_up(1)))) +// TODO need to get size of question list view, as this will change depending on layout +fn question_selected_callback( + question_map: Arc<HashMap<u32, Question>>, + mut s: &mut Cursive, + qid: &u32, +) { + let q = question_map.get(qid).unwrap(); + let XY { x, y: _y } = s.screen_size(); + // Update question view + s.call_on_name(NAME_QUESTION_VIEW, |v: &mut MdView| { + v.set_content(&q.body); + }) + .expect("TODO: make sure this is callable: setting question body on view"); + // Update answer list view + let cb = s + .call_on_name(NAME_ANSWER_LIST, |v: &mut ListView| { + v.reset_with_all(q.answers.iter().map(|a| (preview_answer(x, a), a.id))) }) - .on_pre_event_inner('j', |s, _| { - Some(EventResult::Consumed(Some(s.get_mut().select_down(1)))) - }) - .scrollable() + .expect("TODO why would this ever fail"); + cb(&mut s) +} + +fn preview_question(q: &Question) -> StyledString { + let mut preview = pretty_score(q.score); + preview.append_plain(&q.title); + preview +} + +fn preview_answer(screen_width: usize, a: &Answer) -> StyledString { + let width = cmp::min(a.body.len(), screen_width / 2); + let md = markdown::preview(width, a.body.to_owned()); + let mut preview = pretty_score(a.score); + if a.is_accepted { + preview.append_styled( + "\u{2713} ", // "✔ " + Style::merge(&[ + Style::from(Color::Light(BaseColor::Green)), + Style::from(Effect::Bold), + ]), + ); + } + preview.append(md); + preview +} + +fn pretty_score(score: i32) -> StyledString { + let color = if score > 0 { + Color::Light(BaseColor::Green) + } else { + Color::Light(BaseColor::Red) + }; + SpannedString::styled( + format!("({}) ", score), + Style::merge(&[Style::from(color), Style::from(Effect::Bold)]), + ) } // TODO see cursive/examples/src/bin/select_test.rs for how to test the interface! diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 03df443..634cb29 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -3,5 +3,6 @@ mod entities; mod enumerable; mod markdown; mod ui; +mod views; pub use app::run; diff --git a/src/tui/views.rs b/src/tui/views.rs new file mode 100644 index 0000000..9571341 --- /dev/null +++ b/src/tui/views.rs @@ -0,0 +1,248 @@ +use cursive::event::{Callback, EventResult}; +use cursive::traits::{Finder, Nameable, Resizable, Scrollable}; +use cursive::utils::markup::StyledString; +use cursive::view::{Margins, SizeConstraint, View, ViewWrapper}; +use cursive::views::{ + LinearLayout, NamedView, OnEventView, PaddedView, Panel, ScrollView, SelectView, TextView, +}; +use cursive::{Cursive, Vec2}; +use std::fmt; +use std::fmt::Display; + +use super::markdown; +use crate::error::Result; + +pub const NAME_QUESTION_LIST: &str = "question_list"; +pub const NAME_ANSWER_LIST: &str = "answer_list"; +pub const NAME_QUESTION_VIEW: &str = "question_view"; +pub const NAME_ANSWER_VIEW: &str = "answer_view"; + +// TODO might need resizable wrappers in types + +pub enum Name { + QuestionList, + AnswerList, + QuestionView, + AnswerView, +} + +impl Display for Name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self { + Name::QuestionList => write!(f, "Questions"), + Name::AnswerList => write!(f, "Answers"), + Name::QuestionView => write!(f, "Question"), + Name::AnswerView => write!(f, "Answer"), + } + } +} + +impl From<Name> for String { + fn from(name: Name) -> Self { + match name { + Name::QuestionList => String::from(NAME_QUESTION_LIST), + Name::AnswerList => String::from(NAME_ANSWER_LIST), + Name::QuestionView => String::from(NAME_QUESTION_VIEW), + Name::AnswerView => String::from(NAME_ANSWER_VIEW), + } + } +} + +// TODO maybe I should use cursive's ListView over SelectView ? +pub type ListView = ListViewT<Panel<ScrollView<OnEventView<NamedView<SelectView<u32>>>>>>; + +pub struct ListViewT<T: View> { + inner_name: String, + view: T, +} + +impl<T: View> ViewWrapper for ListViewT<T> { + cursive::wrap_impl!(self.view: T); +} + +impl ListView { + pub fn new<F>(name: Name, on_select: F) -> NamedView<Self> + where + F: Fn(&mut Cursive, &u32) + 'static, + { + ListView::make_new::<StyledString, Vec<_>, _>(name, None, on_select) + } + + pub fn new_with_items<S, I, F>(name: Name, items: I, on_select: F) -> NamedView<Self> + where + S: Into<StyledString>, + I: IntoIterator<Item = (S, u32)>, + F: Fn(&mut Cursive, &u32) + 'static, + { + ListView::make_new(name, Some(items), on_select) + } + + fn make_new<S, I, F>(name: Name, items: Option<I>, on_select: F) -> NamedView<Self> + where + S: Into<StyledString>, + I: IntoIterator<Item = (S, u32)>, + F: Fn(&mut Cursive, &u32) + 'static, + { + let inner_name = name.to_string() + "_inner"; + let mut view = SelectView::new().on_select(on_select); + if let Some(items) = items { + view.add_all(items); + } + let view = view.with_name(&inner_name); + let view = add_vim_bindings(view); + let view = view.scrollable(); + let view = Panel::new(view).title(format!("{}", name)); + let view = ListViewT { view, inner_name }; + view.with_name(name) + } + + pub fn reset_with_all<S, I>(&mut self, iter: I) -> Callback + where + S: Into<StyledString>, + I: IntoIterator<Item = (S, u32)>, + { + self.call_on_inner(|s| { + s.clear(); + s.add_all(iter); + s.set_selection(0) + }) + } + + pub fn select(&mut self, i: usize) -> Callback { + self.call_on_inner(|sv| sv.set_selection(i)) + } + + fn call_on_inner<F, R>(&mut self, cb: F) -> R + where + F: FnOnce(&mut SelectView<u32>) -> R, + { + self.view.call_on_name(&self.inner_name, cb).expect("TODO") + } +} + +pub type MdView = MdViewT<Panel<ScrollView<NamedView<TextView>>>>; + +pub struct MdViewT<T: View> { + inner_name: String, + view: T, +} + +impl<T: View> ViewWrapper for MdViewT<T> { + cursive::wrap_impl!(self.view: T); +} + +impl MdView { + pub fn new(name: Name) -> NamedView<Self> { + let inner_name = name.to_string() + "_inner"; + let view = TextView::empty().with_name(&inner_name); + let view = view.scrollable(); + let view = Panel::new(view); + let view = MdViewT { view, inner_name }; + view.with_name(name) + } + + /// Panics for now, to explore when result is None + pub fn set_content<S>(&mut self, content: S) + where + S: Into<String>, + { + self.view + .call_on_name(&self.inner_name, |tv: &mut TextView| { + tv.set_content(markdown::parse(content)) + }) + .expect("unwrap failed in MdView.set_content") + } +} + +pub type FullLayout = FullLayoutT<PaddedView<LinearLayout>>; + +pub struct FullLayoutT<T: View> { + view: T, + lr_margin: usize, +} + +// TODO set child widths based on parent +impl ViewWrapper for FullLayoutT<PaddedView<LinearLayout>> { + cursive::wrap_impl!(self.view: PaddedView<LinearLayout>); + + fn wrap_layout(&mut self, size: Vec2) { + let margin = self.lr_margin; + let horiz_xy = size.map_x(|x| x / 2 - margin); + for ix in 0..2 { + self.view + .get_inner_mut() + .get_child_mut(ix) + .and_then(|pane| { + // Set top level horizontal constraints + pane.layout(horiz_xy); + // Then coerce the inner linear layouts + pane.downcast_mut() + }) + // And get their children + .and_then(|v: &mut LinearLayout| v.get_child_mut(0)) + // And set the inner vertical constraints + .map(|v| v.layout(horiz_xy.map_y(|y| (ix + 1) * y / 3))); + } + } +} + +impl FullLayout { + pub fn new( + lr_margin: usize, + screen_size: Vec2, + q_list: NamedView<ListView>, + q_view: NamedView<MdView>, + a_list: NamedView<ListView>, + a_view: NamedView<MdView>, + ) -> Self { + let heuristic = 1; + let x = SizeConstraint::Fixed(screen_size.x / 2 - lr_margin - heuristic); + let y_list = SizeConstraint::AtMost(screen_size.y / 3); + let y_view = SizeConstraint::Full; //AtLeast(2 * screen_size.y / 3); + let view = LinearLayout::horizontal() + .child( + // TODO decide whats better, horizontal sizing on the outside, + // or keeping both sizings on the 4 internal views + LinearLayout::vertical() + .child(q_list.resized(x, y_list)) + .child(q_view.resized(x, y_view)) + .with_name("question-pane"), // TODO constants + ) + .child( + LinearLayout::vertical() + .child(a_list.resized(x, y_list)) + .child(a_view.resized(x, y_view)) + .with_name("answer-pane"), + ); + let view = PaddedView::new(Margins::lrtb(lr_margin, lr_margin, 0, 0), view); + FullLayoutT { view, lr_margin } + } +} + +fn add_vim_bindings<T: 'static>( + view: NamedView<SelectView<T>>, +) -> OnEventView<NamedView<SelectView<T>>> +where +{ + OnEventView::new(view) + .on_pre_event_inner('k', |s, _| { + Some(EventResult::Consumed(Some(s.get_mut().select_up(1)))) + }) + .on_pre_event_inner('j', |s, _| { + Some(EventResult::Consumed(Some(s.get_mut().select_down(1)))) + }) +} + +pub enum Layout { + BothColumns, + SingleColumn, + FullScreen, +} + +pub enum Mode { + /// Akin to vim, keys are treated as commands + Normal, + /// Akin to vim, user is typing in bottom prompt + Insert, + // TODO if adding a search feature, that will be anther mode +} |