diff options
Diffstat (limited to 'src/tui/app.rs')
-rw-r--r-- | src/tui/app.rs | 286 |
1 files changed, 167 insertions, 119 deletions
diff --git a/src/tui/app.rs b/src/tui/app.rs index 6658f16..3c2fedf 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -20,134 +20,181 @@ use super::views::{ }; use crate::config::Config; use crate::error::Result; -use crate::stackexchange::{Answer, Question}; +use crate::stackexchange::{Answer, Id, Question, Search}; pub const NAME_HELP_VIEW: &str = "help_view"; -// TODO an Arc<Mutex> app state that gets auto updated with new selections would -// be convenient - -pub fn run(qs: Vec<Question<Markdown>>, cfg: Config) -> Result<()> { - let mut siv = cursive::default(); - siv.load_theme_file(Config::theme_file_path()?).unwrap(); // TODO dont unwrap - - let question_map: HashMap<u32, Question<Markdown>> = - qs.clone().into_iter().map(|q| (q.id, q)).collect(); - let question_map = Arc::new(question_map); - let answer_map: HashMap<u32, Answer<Markdown>> = qs - .clone() - .into_iter() - .flat_map(|q| q.answers.into_iter().map(|a| (a.id, a))) - .collect(); - let answer_map = Arc::new(answer_map); - - let question_view = MdView::new(Name::QuestionView); - let answer_view = MdView::new(Name::AnswerView); - - 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), - ); - - 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)); - }); - - siv.add_layer( - LayoutView::new( - 1, - question_list_view, - question_view, - answer_list_view, - answer_view, - ) - .add_vim_bindings(), - ); - - let cb = siv.call_on_name(NAME_QUESTION_LIST, |v: &mut ListView| v.select(0)); - if let Some(cb) = cb { - cb(&mut siv) +pub struct App { + questions: HashMap<Id, Question<Markdown>>, + answers: HashMap<Id, Answer<Markdown>>, + config: Config, + sites: HashMap<String, String>, +} + +impl App { + pub async fn from_search(search: Search) -> Result<Self> { + let qs = search.search_md().await?; + let questions: HashMap<u32, Question<Markdown>> = + qs.clone().into_iter().map(|q| (q.id, q)).collect(); + let answers: HashMap<u32, Answer<Markdown>> = qs + .into_iter() + .flat_map(|q| q.answers.into_iter().map(|a| (a.id, a))) + .collect(); + Ok(Self { + config: search.config, + sites: search.sites, + questions, + answers, + }) } - // Help / View keymappings - siv.add_global_callback('?', |s| { - if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_HELP_VIEW) { - s.screen_mut().remove_layer(pos); - } else { - s.add_layer(help()); - } - }); - - // Reload theme - siv.add_global_callback(Event::CtrlChar('r'), |s| { - s.load_theme_file(Config::theme_file_path().unwrap()) - .unwrap() - }); - - // Copy contents to sys clipboard - siv.add_global_callback('y', move |s| { - let mut v: ViewRef<LayoutView> = s - .find_name(NAME_FULL_LAYOUT) - .expect("bug: layout view should exist"); - let md = v.get_focused_content(); - if let Some(mut copy_cmd) = cfg.get_copy_cmd() { - let res = (|| { - let mut child = copy_cmd.spawn().map_err(|e| { - if e.kind() == io::ErrorKind::NotFound { - io::Error::new( - io::ErrorKind::Other, - "couldn't exec copy cmd; you may need to configure it manually", - ) - } else { - e - } - })?; - let mut stdin = child.stdin.take().ok_or_else(|| { - io::Error::new(io::ErrorKind::Other, "couldn't get stdin of copy cmd") - })?; - stdin.write_all(md.source().as_bytes())?; - Ok("copied to clipboard!".to_string()) - })(); - temp_feedback_msg(s, res); - } - }); + // TODO a <Mutex> app field that gets auto updated with new selections would be convenient + pub fn run(self) -> Result<()> { + // The underlying fields of self are just static data that we + // borrow from various places and callbacks; wrap in Arc to just have + // one allocation that gets referenced from wherever. + let arc = Arc::new(self); - siv.add_global_callback(Event::Key(Key::Esc), |s| { - if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_HELP_VIEW) { - s.screen_mut().remove_layer(pos); - } - if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_TEMP_MSG) { - s.screen_mut().remove_layer(pos); + let mut siv = cursive::default(); + siv.load_theme_file(Config::theme_file_path()?).unwrap(); // TODO dont unwrap + + let question_view = MdView::new(Name::QuestionView); + let answer_view = MdView::new(Name::AnswerView); + + let arc2 = arc.clone(); + let question_list_view = ListView::new_with_items( + Name::QuestionList, + arc.questions + .clone() + .into_values() + .map(|q| (preview_question(&q), q.id)), + move |s, qid| arc2.question_selected_callback(s, *qid), + ); + + let arc2 = arc.clone(); + let answer_list_view = ListView::new(Name::AnswerList, move |s, aid| { + let a = arc2.answers.get(aid).unwrap(); + s.call_on_name(NAME_ANSWER_VIEW, |v: &mut MdView| v.set_content(&a.body)); + }); + + siv.add_layer( + LayoutView::new( + 1, + question_list_view, + question_view, + answer_list_view, + answer_view, + ) + .add_vim_bindings(), + ); + + let cb = siv.call_on_name(NAME_QUESTION_LIST, |v: &mut ListView| v.select(0)); + if let Some(cb) = cb { + cb(&mut siv) } - }); - // Run the app - siv.run(); - Ok(()) -} + // Help / View keymappings + siv.add_global_callback('?', |s| { + if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_HELP_VIEW) { + s.screen_mut().remove_layer(pos); + } else { + s.add_layer(help()); + } + }); + + // Reload theme + siv.add_global_callback(Event::CtrlChar('r'), |s| { + s.load_theme_file(Config::theme_file_path().unwrap()) + .unwrap() + }); + + // Copy contents to sys clipboard + let arc2 = arc.clone(); + siv.add_global_callback('y', move |s| { + let mut v: ViewRef<LayoutView> = s + .find_name(NAME_FULL_LAYOUT) + .expect("bug: layout view should exist"); + let md = v.get_focused_content(); + if let Some(mut copy_cmd) = arc2.config.get_copy_cmd() { + let res = (|| { + let mut child = copy_cmd.spawn().map_err(|e| { + if e.kind() == io::ErrorKind::NotFound { + io::Error::new( + io::ErrorKind::Other, + "couldn't exec copy cmd; you may need to configure it manually", + ) + } else { + e + } + })?; + let mut stdin = child.stdin.take().ok_or_else(|| { + io::Error::new(io::ErrorKind::Other, "couldn't get stdin of copy cmd") + })?; + stdin.write_all(md.source().as_bytes())?; + Ok("copied to clipboard!".to_string()) + })(); + temp_feedback_msg(s, res); + } + }); + + // Open in browser + let arc2 = arc; + siv.add_global_callback('o', move |s| { + let mut v: ViewRef<LayoutView> = s + .find_name(NAME_FULL_LAYOUT) + .expect("bug: layout view should exist"); + if let Some((qid, aid)) = v.get_focused_ids() { + let res = webbrowser::open(&arc2.mk_url("stackoverflow".to_string(), qid, aid)) + .map(|_| "opened stackexchange in the browser!".to_string()); + dbg!(&res); + temp_feedback_msg(s, res); + } + }); + + siv.add_global_callback(Event::Key(Key::Esc), |s| { + if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_HELP_VIEW) { + s.screen_mut().remove_layer(pos); + } + if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_TEMP_MSG) { + s.screen_mut().remove_layer(pos); + } + }); -fn question_selected_callback( - question_map: Arc<HashMap<u32, Question<Markdown>>>, - s: &mut Cursive, - qid: u32, -) { - let q = question_map.get(&qid).unwrap(); - let body = &q.body; - let XY { x, y: _y } = s.screen_size(); - // Update question view - s.call_on_name(NAME_QUESTION_VIEW, |v: &mut MdView| { - v.set_content(body); - }) - .expect("Panic: setting question view content failed"); - // 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))) + // Run the app + siv.run(); + Ok(()) + } + + fn mk_url(&self, site: String, question_id: Id, answer_id_opt: Option<Id>) -> String { + // answer link actually doesn't need question id + let site_url = self + .sites + .get(&site) + .expect("we lost track of a site?!") + .to_string(); + match answer_id_opt { + Some(answer_id) => format!("https://{site_url}/a/{answer_id}"), + None => format!("https://{site_url}/a/{question_id}"), + } + } + + pub fn question_selected_callback(&self, s: &mut Cursive, qid: u32) { + let q = self.questions.get(&qid).unwrap(); + let body = &q.body; + let XY { x, y: _y } = s.screen_size(); + // Update question view + s.call_on_name(NAME_QUESTION_VIEW, |v: &mut MdView| { + v.set_content(body); }) - .expect("Panic: setting answer list content failed"); - cb(s) + .expect("Panic: setting question view content failed"); + // 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))) + }) + .expect("Panic: setting answer list content failed"); + cb(s) + } } fn preview_question(q: &Question<Markdown>) -> StyledString { @@ -201,6 +248,7 @@ pub fn help() -> Dialog { **G**: Scroll To Bottom ## Misc +**o**: Open current q/a in the browser **y**: Copy current q/a to the clipboard **q, ZZ, Ctrl<c>**: Exit **Ctrl<r>**: Reload theme @@ -216,7 +264,7 @@ pub fn help() -> Dialog { } pub fn temp_feedback_msg(siv: &mut Cursive, msg: io::Result<String>) { - // TODO semaphore to close existing msg before displaying new one + // TODO semaphore to close existing msg before displaying new one? let style = if msg.is_ok() { Color::Light(BaseColor::Green) } else { |