diff options
author | Sam Tay <samctay@pm.me> | 2022-08-20 23:00:23 -0700 |
---|---|---|
committer | Sam Tay <samctay@pm.me> | 2022-08-20 23:00:23 -0700 |
commit | 5cecf46b1b32243ebef823590c71a7eb8521bb5d (patch) | |
tree | fbb31f84a8d77d9ca1ab91614b925a02cc5395d1 | |
parent | cffdef897645ec6f9fd71760f7b4849b46036c9b (diff) |
Add shortcut to copy contents to clipboard
-rw-r--r-- | src/cli.rs | 5 | ||||
-rw-r--r-- | src/config.rs | 21 | ||||
-rw-r--r-- | src/main.rs | 13 | ||||
-rw-r--r-- | src/stackexchange/scraper.rs | 2 | ||||
-rw-r--r-- | src/tui/app.rs | 74 | ||||
-rw-r--r-- | src/tui/views.rs | 49 |
6 files changed, 147 insertions, 17 deletions
@@ -137,8 +137,7 @@ where sites: matches .values_of("site") .unwrap() - .map(|s| s.split(';')) - .flatten() + .flat_map(|s| s.split(';')) .map(String::from) .collect(), api_key: matches @@ -146,6 +145,7 @@ where .map(String::from) .or(config.api_key), lucky, + ..config }, }) } @@ -166,6 +166,7 @@ mod tests { String::from("yeah"), ], search_engine: SearchEngine::DuckDuckGo, + copy_cmd: Some(String::from("wl-copy")), } } diff --git a/src/config.rs b/src/config.rs index 99df1cc..f5e12b5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,8 @@ use std::fmt; use std::fs; use std::io::Write; use std::path::PathBuf; +use std::process::Command; +use std::process::Stdio; use crate::error::{Error, Result}; use crate::utils; @@ -24,6 +26,7 @@ pub struct Config { pub lucky: bool, pub sites: Vec<String>, pub search_engine: SearchEngine, + pub copy_cmd: Option<String>, } impl fmt::Display for SearchEngine { @@ -52,6 +55,16 @@ impl Default for Config { lucky: true, sites: vec![String::from("stackoverflow")], search_engine: SearchEngine::default(), + copy_cmd: Some(String::from(if cfg!(target_os = "macos") { + "pbcopy" + } else if cfg!(target_os = "windows") { + "clip" + } else if cfg!(target_os = "linux") { + "xclip -sel clip" + } else { + // this default makes no sense but w/e + "wl-copy" + })), } } } @@ -115,4 +128,12 @@ impl Config { let file = utils::create_file(&filename)?; Ok(serde_yaml::to_writer(file, &self)?) } + + pub fn get_copy_cmd(&self) -> Option<Command> { + let copy_cmd_str = self.copy_cmd.as_ref()?; + let mut pieces = copy_cmd_str.split_whitespace(); + let mut cmd = Command::new(pieces.next()?); + cmd.args(pieces).stdin(Stdio::piped()); + Some(cmd) + } } diff --git a/src/main.rs b/src/main.rs index 3a07158..8f235af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,7 @@ fn main() -> Result<()> { .block_on(run()) .map(|qs| { // Run TUI - qs.map(tui::run); + qs.map(|(qs, cfg)| tui::run(qs, cfg)); }) .or_else(|e: Error| { // Handle errors @@ -31,7 +31,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>>>> { +async fn run() -> Result<Option<(Vec<Question<Markdown>>, Config)>> { // Get CLI opts let opts = cli::get_opts()?; let config = opts.config; @@ -78,7 +78,7 @@ async fn run() -> Result<Option<Vec<Question<Markdown>>>> { } if let Some(q) = opts.query { - let mut search = Search::new(config, ls, q); + let mut search = Search::new(config.clone(), ls, q); if lucky { // Show top answer let md = Term::wrap_spinner(search.search_lucky()).await??; @@ -92,9 +92,12 @@ async fn run() -> Result<Option<Vec<Question<Markdown>>>> { } // Get the rest of the questions - return Ok(Some(Term::wrap_spinner(qs).await?.unwrap()?)); + return Ok(Some((Term::wrap_spinner(qs).await?.unwrap()?, config))); } else { - return Ok(Some(Term::wrap_spinner(search.search_md()).await??)); + return Ok(Some(( + Term::wrap_spinner(search.search_md()).await??, + config, + ))); } } Ok(None) diff --git a/src/stackexchange/scraper.rs b/src/stackexchange/scraper.rs index b1354fa..156c68f 100644 --- a/src/stackexchange/scraper.rs +++ b/src/stackexchange/scraper.rs @@ -325,7 +325,7 @@ mod tests { ); match DuckDuckGo.parse(html, &sites, 2) { - Err(Error::Scraping(s)) if s == "DuckDuckGo blocked this request".to_string() => Ok(()), + Err(Error::Scraping(s)) if s == *"DuckDuckGo blocked this request" => Ok(()), _ => Err(String::from("Failed to detect DuckDuckGo blocker")), } } diff --git a/src/tui/app.rs b/src/tui/app.rs index 7160cce..0aa7840 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,27 +1,33 @@ -use cursive::event::Event; +use cursive::event::{Event, Key}; use cursive::theme::{BaseColor, Color, Effect, Style}; use cursive::traits::{Nameable, Scrollable}; use cursive::utils::markup::StyledString; use cursive::utils::span::SpannedString; -use cursive::views::{Dialog, TextView}; +use cursive::views::{Dialog, TextView, ViewRef}; use cursive::Cursive; use cursive::XY; use std::collections::HashMap; +use std::io; +use std::io::Write; use std::sync::Arc; use super::markdown; use super::markdown::Markdown; use super::views::{ - LayoutView, ListView, MdView, Name, Vimable, NAME_ANSWER_LIST, NAME_ANSWER_VIEW, - NAME_QUESTION_LIST, NAME_QUESTION_VIEW, + LayoutView, ListView, MdView, Name, TempView, Vimable, NAME_ANSWER_LIST, NAME_ANSWER_VIEW, + NAME_FULL_LAYOUT, NAME_QUESTION_LIST, NAME_QUESTION_VIEW, }; use crate::config::Config; use crate::error::Result; use crate::stackexchange::{Answer, Question}; pub const NAME_HELP_VIEW: &str = "help_view"; +pub const NAME_TEMP_MSG: &str = "tmp_msg_view"; -pub fn run(qs: Vec<Question<Markdown>>) -> Result<()> { +// 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 @@ -31,8 +37,7 @@ pub fn run(qs: Vec<Question<Markdown>>) -> Result<()> { let answer_map: HashMap<u32, Answer<Markdown>> = qs .clone() .into_iter() - .map(|q| q.answers.into_iter().map(|a| (a.id, a))) - .flatten() + .flat_map(|q| q.answers.into_iter().map(|a| (a.id, a))) .collect(); let answer_map = Arc::new(answer_map); @@ -74,11 +79,51 @@ pub fn run(qs: Vec<Question<Markdown>>) -> Result<()> { 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); + } + }); + + 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); + } + }); + + // Run the app siv.run(); Ok(()) } @@ -156,6 +201,7 @@ pub fn help() -> Dialog { **G**: Scroll To Bottom ## Misc +**y**: Copy current q/a to the clipboard **q, ZZ, Ctrl<c>**: Exit **Ctrl<r>**: Reload theme **?**: Toggle this help menu @@ -169,5 +215,19 @@ pub fn help() -> Dialog { .title("Help") } +pub fn temp_feedback_msg(siv: &mut Cursive, msg: io::Result<String>) { + // TODO semaphore to close existing msg before displaying new one + let style = if msg.is_ok() { + Color::Light(BaseColor::Green) + } else { + Color::Light(BaseColor::Red) + }; + let content = msg.unwrap_or_else(|e| format!("error: {}", e)); + let styled_content = SpannedString::styled(content, style); + let layer = Dialog::around(TextView::new(styled_content)); + let temp = TempView::new(layer).with_name(NAME_TEMP_MSG); + siv.add_layer(temp); +} + // TODO see cursive/examples/src/bin/select_test.rs for how to test the interface! // maybe see if we can conditionally run when --nocapture is passed? diff --git a/src/tui/views.rs b/src/tui/views.rs index 5341630..af015ef 100644 --- a/src/tui/views.rs +++ b/src/tui/views.rs @@ -262,7 +262,15 @@ impl MdView { .call_on_name(&self.inner_name, |tv: &mut TextView| { tv.set_content(content.clone()) }) - .expect("unwrap failed in MdView.set_content") + .expect("couldn't find mdview") + } + + pub fn get_content(&mut self) -> Markdown { + self.view + .call_on_name(&self.inner_name, |tv: &mut TextView| { + tv.get_content().clone() + }) + .expect("couldn't find mdview") } pub fn show_title(&mut self) { @@ -372,6 +380,22 @@ impl LayoutView { .with_name(NAME_FULL_LAYOUT) } + // Get the name of the currently focused pane + pub fn get_focused_name(&self) -> &'static str { + Self::xy_to_name(self.get_focused_index()) + } + + // Get the question or answer markdown content, whichever side is focused + pub fn get_focused_content(&mut self) -> Markdown { + let name = match self.get_focused_name() { + NAME_QUESTION_VIEW | NAME_QUESTION_LIST => NAME_QUESTION_VIEW, + _ => NAME_ANSWER_VIEW, + }; + self.view + .call_on_name(name, |v: &mut MdView| v.get_content()) + .expect("call on md view failed") + } + fn get_constraints(&self, screen_size: Vec2) -> LayoutViewSizing { let heuristic = 1; let width = SizeConstraint::Fixed(screen_size.x / 2 - heuristic); @@ -438,7 +462,7 @@ impl LayoutView { } fn refocus(&mut self) { - let name = Self::xy_to_name(self.get_focused_index()); + let name = self.get_focused_name(); match self.layout { Layout::SingleColumn if name == NAME_QUESTION_LIST || name == NAME_QUESTION_VIEW => { self.view @@ -618,3 +642,24 @@ pub trait Vimable: View + Sized { } impl<T: View> Vimable for T {} + +pub struct TempView<T: View> { + view: T, +} + +// TODO figure out how to auto close this in 3-5s +impl<T: View> ViewWrapper for TempView<T> { + cursive::wrap_impl!(self.view: T); + + fn wrap_on_event(&mut self, _event: Event) -> EventResult { + EventResult::with_cb(|s| { + s.pop_layer(); + }) + } +} + +impl<T: View> TempView<T> { + pub fn new(view: T) -> Self { + Self { view } + } +} |