diff options
author | Sam Tay <sam.chong.tay@gmail.com> | 2020-06-11 16:29:10 -0700 |
---|---|---|
committer | Sam Tay <sam.chong.tay@gmail.com> | 2020-06-11 17:01:53 -0700 |
commit | 020a9390db41da75233e41db1c58549a600e1011 (patch) | |
tree | 299fb081780658c3b6545bc98a18452cafb19e21 | |
parent | 37ab4c85f9d8bf1e113c4b1b419c0d17ec72b84e (diff) |
Visible question & answer list
-rw-r--r-- | TODO.md | 1 | ||||
-rw-r--r-- | src/config.rs | 72 | ||||
-rw-r--r-- | src/stackexchange.rs | 5 | ||||
-rw-r--r-- | src/tui/app.rs | 93 | ||||
-rw-r--r-- | src/tui/markdown.rs | 8 |
5 files changed, 155 insertions, 24 deletions
@@ -7,7 +7,6 @@ benefit of incorporating termimad features will not be felt. But, this is changing [soon](https://meta.stackexchange.com/q/348746). ### v0.2.0 -0. remove cruft from playing with tui-rs 0. Cursive interface for viewing questions and answers 1. Focus in one of four areas 3. arrows and vim bindings to manipulate lists and scroll, depending on focus diff --git a/src/config.rs b/src/config.rs index 58b51f6..7fc3e83 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use std::fs; +use std::io::Write; use std::path::PathBuf; use crate::error::{Error, Result}; @@ -13,6 +14,7 @@ pub struct Config { pub site: String, } +// TODO make a friender config file, like the colors.toml below impl Default for Config { fn default() -> Self { Config { @@ -39,6 +41,26 @@ pub fn user_config() -> Result<Config> { } } +pub fn set_api_key(key: String) -> Result<()> { + let mut cfg = user_config()?; + cfg.api_key = Some(key); + write_config(&cfg) +} + +/// Get project directory +pub fn project_dir() -> Result<ProjectDirs> { + ProjectDirs::from("io", "Sam Tay", "so").ok_or_else(|| Error::ProjectDir) +} + +pub fn theme_file_name() -> Result<PathBuf> { + let name = project_dir()?.config_dir().join("colors.toml"); + if !name.as_path().exists() { + let mut file = utils::create_file(&name)?; + file.write_all(DEFAULT_COLORS_TOML.as_bytes())?; + } + Ok(name) +} + fn write_config(config: &Config) -> Result<()> { let filename = config_file_name()?; let file = utils::create_file(&filename)?; @@ -50,13 +72,45 @@ fn config_file_name() -> Result<PathBuf> { Ok(project_dir()?.config_dir().join("config.yml")) } -/// Get project directory -pub fn project_dir() -> Result<ProjectDirs> { - ProjectDirs::from("io", "Sam Tay", "so").ok_or_else(|| Error::ProjectDir) -} +// TODO either try to respect terminal defaults, or go all in one solarized or +// tomorrow night 80s +static DEFAULT_COLORS_TOML: &str = r##" +# Every field in a theme file is optional. -pub fn set_api_key(key: String) -> Result<()> { - let mut cfg = user_config()?; - cfg.api_key = Some(key); - write_config(&cfg) -} +shadow = false +borders = "outset" # Alternatives are "none" and "simple" + +# Base colors are red, green, blue, +# cyan, magenta, yellow, white and black. +[colors] + # There are 3 ways to select a color: + # - The 16 base colors are selected by name: + # "blue", "light red", "magenta", ... + # - Low-resolution colors use 3 characters, each <= 5: + # "541", "003", ... + # - Full-resolution colors start with '#' and can be 3 or 6 hex digits: + # "#1A6", "#123456", ... + + # If the value is an array, the first valid + # and supported color will be used. + background = ["default"] + + # If the terminal doesn't support custom color (like the linux TTY), + # non-base colors will be skipped. + shadow = [] + view = "111" + + # An array with a single value has the same effect as a simple value. + primary = [] + secondary = "cyan" + tertiary = "green" + + # Hex values can use lower or uppercase. + # (base color MUST be lowercase) + title_primary = ["BLUE", "yellow"] # `BLUE` will be skipped. + title_secondary = "#ffff55" + + # Lower precision values can use only 3 digits. + highlight = "#F88" + highlight_inactive = "#5555FF" +"##; diff --git a/src/stackexchange.rs b/src/stackexchange.rs index 183e64f..941aad6 100644 --- a/src/stackexchange.rs +++ b/src/stackexchange.rs @@ -44,7 +44,7 @@ pub struct Site { /// Represents a StackExchange answer with a custom selection of fields from /// the [StackExchange docs](https://api.stackexchange.com/docs/types/answer) -#[derive(Deserialize, Debug)] +#[derive(Clone, Deserialize, Debug)] pub struct Answer { #[serde(rename = "answer_id")] pub id: u32, @@ -56,8 +56,9 @@ pub struct Answer { /// 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 // TODO let body be a generic that implements Display! -#[derive(Deserialize, Debug)] +#[derive(Clone, Deserialize, Debug)] pub struct Question { #[serde(rename = "question_id")] pub id: u32, diff --git a/src/tui/app.rs b/src/tui/app.rs index c079828..261dd4a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,8 +1,12 @@ -use cursive::views::TextView; +use cursive::traits::Nameable; +use cursive::views::{LinearLayout, NamedView, SelectView, TextContent, TextView}; +use std::collections::HashMap; +use std::sync::Arc; use super::markdown; +use crate::config; use crate::error::Result; -use crate::stackexchange::Question; +use crate::stackexchange::{Answer, Question}; // ----------------------------------------- // |question title list|answer preview list| 1/3 @@ -59,23 +63,90 @@ pub enum Mode { // 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 + + //app state + //put this in siv.set_user_data? hmm + //TODO maybe this isn't necessary until multithreading + + let question_map: HashMap<u32, Question> = qs.clone().into_iter().map(|q| (q.id, q)).collect(); + let question_map = Arc::new(question_map); + let answer_map: HashMap<u32, Answer> = qs + .clone() + .into_iter() + .map(|q| q.answers.into_iter().map(|a| (a.id, a))) + .flatten() + .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"); + + // 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"); + + // question list view + //let question_map_ = question_map.clone(); + //let current_question_ = current_question.clone(); + let question_list_view: NamedView<SelectView<u32>> = SelectView::new() + .autojump() // ? probably not... + .with_all(qs.into_iter().map(|q| (q.title, q.id))) + .on_select(move |s, qid| { + let q = question_map.get(qid).unwrap().clone(); + let q_body = q.body; + let q_ans = q.answers; + current_question.set_content(markdown::parse(q_body)); + s.call_on_name("answer_list", move |v: &mut SelectView<u32>| { + v.clear(); + v.add_all(q_ans.into_iter().map(|a| { + // TODO dedup newlines, split newlines, join with spaces + // add ellipses + // set const for cutoff + // add score & accepted checkmark + let mut a_body = a.body.clone(); + a_body.truncate(50); + (markdown::parse(a_body), a.id) + })); + }); // TODO select initial answer + }) // TODO select initial question + .with_name("question_list"); + + // answer list view + //let answer_map_ = answer_map.clone(); + //let current_answer_ = current_question.clone(); + let answer_list_view: NamedView<SelectView<u32>> = SelectView::new() + .autojump() + .on_select(move |_, aid| { + let a = answer_map.get(aid).unwrap().clone(); + current_answer.set_content(markdown::parse(a.body)); + }) + .with_name("answer_list"); //TODO eventually do this in the right place, e.g. abstract out md //parser, write benches, & do within threads - let md = markdown::parse( - qs[0].answers[0] - .body - .clone() - .replace("<kbd>", "**[") - .replace("</kbd>", "]**"), + siv.add_layer( + LinearLayout::horizontal() + .child( + LinearLayout::vertical() + .child(question_list_view) + .child(question_view), + ) + .child( + LinearLayout::vertical() + .child(answer_list_view) + .child(answer_view), + ), ); - siv.add_layer(TextView::new(md)); - siv.run(); Ok(()) } -// TODO prettier and more valuable tests +// 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? #[cfg(test)] mod tests { use super::*; diff --git a/src/tui/markdown.rs b/src/tui/markdown.rs index 64d3141..de031ed 100644 --- a/src/tui/markdown.rs +++ b/src/tui/markdown.rs @@ -19,7 +19,12 @@ pub fn parse<S>(input: S) -> StyledString where S: Into<String>, { - let input = input.into(); + // TODO handle other stackexchange oddities here + // TODO then benchmark + let input = input + .into() + .replace("<kbd>", "**[") + .replace("</kbd>", "]**"); let spans = parse_spans(&input); @@ -183,6 +188,7 @@ pub fn parse_spans(input: &str) -> Vec<StyledIndexedSpan> { // TODO update these tests (some expectations will be different now) // TODO and add more! bang on the code, lists, etc. // use this as an opportunity to see how pulldown_cmark splits things up +// TODO: how to reverse a list in Python answer is broken; test it here! #[cfg(test)] mod tests { use super::*; |