summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSam Tay <samctay@pm.me>2022-08-20 23:00:23 -0700
committerSam Tay <samctay@pm.me>2022-08-20 23:00:23 -0700
commit5cecf46b1b32243ebef823590c71a7eb8521bb5d (patch)
treefbb31f84a8d77d9ca1ab91614b925a02cc5395d1
parentcffdef897645ec6f9fd71760f7b4849b46036c9b (diff)
Add shortcut to copy contents to clipboard
-rw-r--r--src/cli.rs5
-rw-r--r--src/config.rs21
-rw-r--r--src/main.rs13
-rw-r--r--src/stackexchange/scraper.rs2
-rw-r--r--src/tui/app.rs74
-rw-r--r--src/tui/views.rs49
6 files changed, 147 insertions, 17 deletions
diff --git a/src/cli.rs b/src/cli.rs
index 707ed3e..18c1a3f 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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 }
+ }
+}