diff options
author | Sam Tay <samctay@pm.me> | 2022-08-21 17:57:55 -0700 |
---|---|---|
committer | Sam Tay <samctay@pm.me> | 2022-08-21 18:05:30 -0700 |
commit | 7377525de6cf78946b01dcb69aae7bb52a9bf74b (patch) | |
tree | 8eeedc26c0acc84807b34682be62e910b8dc8456 /src | |
parent | 1484f6763b9a7dd782a002764886c053477f2181 (diff) |
Add key to open SE answer in browser
Diffstat (limited to 'src')
-rw-r--r-- | src/main.rs | 20 | ||||
-rw-r--r-- | src/stackexchange/api.rs | 20 | ||||
-rw-r--r-- | src/stackexchange/mod.rs | 2 | ||||
-rw-r--r-- | src/stackexchange/search.rs | 9 | ||||
-rw-r--r-- | src/tui/app.rs | 286 | ||||
-rw-r--r-- | src/tui/mod.rs | 2 | ||||
-rw-r--r-- | src/tui/views.rs | 21 |
7 files changed, 217 insertions, 143 deletions
diff --git a/src/main.rs b/src/main.rs index c2e8f51..e732d5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,17 +13,16 @@ use tokio::task; use config::Config; use error::{Error, Result}; -use stackexchange::{LocalStorage, Question, Search}; +use stackexchange::{LocalStorage, Search}; use term::Term; -use tui::markdown::Markdown; fn main() -> Result<()> { // Tokio runtime Runtime::new()? .block_on(run()) - .map(|qs| { + .map(|app| { // Run TUI - qs.map(|(qs, cfg)| tui::run(qs, cfg)); + app.map(tui::App::run); }) .or_else(|e: Error| { // Handle errors @@ -33,7 +32,7 @@ fn main() -> Result<()> { /// Runs the CLI and, if the user wishes to enter the TUI, returns /// question/answer data -async fn run() -> Result<Option<(Vec<Question<Markdown>>, Config)>> { +async fn run() -> Result<Option<tui::App>> { // Get CLI opts let opts = cli::get_opts()?; let config = opts.config; @@ -88,18 +87,17 @@ async fn run() -> Result<Option<(Vec<Question<Markdown>>, Config)>> { term.print("\nPress **[SPACE]** to see more results, or any other key to exit"); // Kick off the rest of the search in the background - let qs = task::spawn(async move { search.search_md().await }); + let app = task::spawn(async move { tui::App::from_search(search).await }); if !Term::wait_for_char(' ')? { return Ok(None); } // Get the rest of the questions - return Ok(Some((Term::wrap_spinner(qs).await?.unwrap()?, config))); + return Ok(Some(Term::wrap_spinner(app).await?.unwrap()?)); } else { - return Ok(Some(( - Term::wrap_spinner(search.search_md()).await??, - config, - ))); + return Ok(Some( + Term::wrap_spinner(tui::App::from_search(search)).await??, + )); } } Ok(None) diff --git a/src/stackexchange/api.rs b/src/stackexchange/api.rs index cf326f2..57f8f88 100644 --- a/src/stackexchange/api.rs +++ b/src/stackexchange/api.rs @@ -21,12 +21,14 @@ const SE_FILTER: &str = ".DND5X2VHHUH8HyJzpjo)5NvdHI3w6auG"; /// Pagesize when fetching all SE sites. Should be good for many years... const SE_SITES_PAGESIZE: u16 = 10000; +pub type Id = u32; + /// Represents a StackExchange answer with a custom selection of fields from /// the [StackExchange docs](https://api.stackexchange.com/docs/types/answer) #[derive(Clone, Deserialize, Debug)] pub struct Answer<S> { #[serde(rename = "answer_id")] - pub id: u32, + pub id: Id, pub score: i32, #[serde(rename = "body_markdown")] pub body: S, @@ -35,19 +37,21 @@ pub struct Answer<S> { /// Represents a StackExchange question with a custom selection of fields from /// the [StackExchange docs](https://api.stackexchange.com/docs/types/question) -// TODO container over answers should be generic iterator #[derive(Clone, Deserialize, Debug)] pub struct Question<S> { #[serde(rename = "question_id")] - pub id: u32, + pub id: Id, pub score: i32, - #[serde(default = "Vec::new")] // N.B. empty vector default needed because questions endpoint cannot filter // answers >= 1 + #[serde(default = "Vec::new")] pub answers: Vec<Answer<S>>, pub title: String, #[serde(rename = "body_markdown")] pub body: S, + // This is the only field that doesn't actually come back from SE; we add + // this site code to which the question belongs + pub site: Option<String>, } /// Internal struct that represents the boilerplate response wrapper from SE API. @@ -98,7 +102,7 @@ impl Api { .into_iter() .filter(|q| !q.answers.is_empty()) .collect(); - Ok(Self::preprocess(qs)) + Ok(Self::preprocess(site, qs)) } /// Search against the SE site's /search/advanced endpoint with a given query. @@ -126,7 +130,7 @@ impl Api { .json::<ResponseWrapper<Question<String>>>() .await? .items; - Ok(Self::preprocess(qs)) + Ok(Self::preprocess(site, qs)) } pub async fn sites(&self) -> Result<Vec<Site>> { @@ -159,9 +163,10 @@ impl Api { } /// Sorts answers by score + /// Add the site code to which the question belongs /// Preprocess SE markdown to "cmark" markdown (or something closer to it) /// This markdown preprocess _always_ happens. - fn preprocess(qs: Vec<Question<String>>) -> Vec<Question<String>> { + fn preprocess(site: &str, qs: Vec<Question<String>>) -> Vec<Question<String>> { qs.into_par_iter() .map(|q| { let mut answers = q.answers; @@ -175,6 +180,7 @@ impl Api { .collect(); Question { answers, + site: Some(site.to_string()), body: markdown::preprocess(q.body), ..q } diff --git a/src/stackexchange/mod.rs b/src/stackexchange/mod.rs index b0e1345..387ae54 100644 --- a/src/stackexchange/mod.rs +++ b/src/stackexchange/mod.rs @@ -4,6 +4,6 @@ mod search; // Exposed for benchmarking pub mod scraper; -pub use api::{Answer, Question}; +pub use api::{Answer, Id, Question}; pub use local_storage::LocalStorage; pub use search::Search; diff --git a/src/stackexchange/search.rs b/src/stackexchange/search.rs index 1357c80..d705867 100644 --- a/src/stackexchange/search.rs +++ b/src/stackexchange/search.rs @@ -26,10 +26,10 @@ const USER_AGENT: &str = // TODO this really needs a better name... #[derive(Clone)] pub struct Search { - api: Api, - config: Config, - query: String, - sites: HashMap<String, String>, + pub api: Api, + pub config: Config, + pub query: String, + pub sites: HashMap<String, String>, } impl Search { @@ -190,6 +190,7 @@ fn parse_markdown(qs: Vec<Question<String>>) -> Vec<Question<Markdown>> { id: q.id, score: q.score, title: q.title, + site: q.site, } }) .collect::<Vec<_>>() 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 { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 9bdd9da..07b8421 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2,4 +2,4 @@ mod app; pub mod markdown; mod views; -pub use app::run; +pub use app::App; diff --git a/src/tui/views.rs b/src/tui/views.rs index 0e7971d..ef96a6a 100644 --- a/src/tui/views.rs +++ b/src/tui/views.rs @@ -155,6 +155,10 @@ impl ListView { view.with_name(name) } + pub fn get_current_selection(&mut self) -> Option<u32> { + self.call_on_inner(|sv| sv.selection().as_deref().copied()) + } + pub fn reset_with_all<S, I>(&mut self, iter: I) -> Callback where S: Into<StyledString>, @@ -399,6 +403,23 @@ impl LayoutView { .expect("call on md view failed") } + // There may be no questions and there may be no answers? There should be answers but w/e + pub fn get_focused_ids(&mut self) -> Option<(u32, Option<u32>)> { + let curr_question = self + .view + .call_on_name(NAME_QUESTION_LIST, |v: &mut ListView| { + v.get_current_selection() + }) + .flatten()?; + let curr_answer = self + .view + .call_on_name(NAME_ANSWER_LIST, |v: &mut ListView| { + v.get_current_selection() + }) + .flatten(); + Some((curr_question, curr_answer)) + } + fn get_constraints(&self, screen_size: Vec2) -> LayoutViewSizing { let heuristic = 1; let width = SizeConstraint::Fixed(screen_size.x / 2 - heuristic); |