diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/config.rs | 1 | ||||
-rw-r--r-- | src/error.rs | 4 | ||||
-rw-r--r-- | src/main.rs | 38 | ||||
-rw-r--r-- | src/stackexchange.rs | 2 | ||||
-rw-r--r-- | src/tui/app.rs | 192 | ||||
-rw-r--r-- | src/tui/list.rs | 60 | ||||
-rw-r--r-- | src/tui/markdown.hs | 3 | ||||
-rw-r--r-- | src/tui/markdown.rs | 330 | ||||
-rw-r--r-- | src/tui/mod.rs | 2 |
9 files changed, 314 insertions, 318 deletions
diff --git a/src/config.rs b/src/config.rs index af51de3..58b51f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -45,6 +45,7 @@ fn write_config(config: &Config) -> Result<()> { Ok(serde_yaml::to_writer(file, config)?) } +// TODO consider switching to .toml to be consistent with colors.toml fn config_file_name() -> Result<PathBuf> { Ok(project_dir()?.config_dir().join("config.yml")) } diff --git a/src/error.rs b/src/error.rs index 86fb55f..35847c3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,8 +26,8 @@ pub enum Error { ProjectDir, #[error("Empty sites file in cache")] EmptySites, - #[error("Sorry, couldn't find any answers for your query")] - NoResults, + //#[error("Sorry, couldn't find any answers for your query")] + //NoResults, } #[derive(Debug)] diff --git a/src/main.rs b/src/main.rs index f66e192..e099d21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -#![allow(dead_code, unused_imports, unused_mut, unused_variables)] mod cli; mod config; mod error; @@ -65,40 +64,13 @@ fn main() { } if let Some(q) = opts.query { - //let se = StackExchange::new(config); - // TODO async - //let qs = se.search(&q)?; - //let ans = que.first().ok_or(Error::NoResults)?.answers.first().expect( - //"StackExchange returned a question with no answers; \ - //this shouldn't be possible!", - //); - // TODO eventually do this in the right place, e.g. abstract out md parser, write benches, & do within threads - // TODO do the below for --lucky with option to continue - //let md = ans.body.replace("<kbd>", "**[").replace("</kbd>", "]**"); + let se = StackExchange::new(config); + //TODO async + let qs = se.search(&q)?; + //TODO do the print_text below for --lucky with option to continue //skin.print_text(&md); - use crate::stackexchange::{Answer, Question}; - tui::run(vec![Question { - id: 42, - score: 323, - title: "How do I exit Vim?".to_string(), - body: "yo this be my problem dawg but don't say **do** `this`".to_string(), - answers: vec![ - Answer { - id: 422, - score: -4, - body: "# bad\nthis is my bad answer".to_string(), - is_accepted: false, - }, - Answer { - id: 423, - score: 23, - body: "this is a *good* answer tho".to_string(), - is_accepted: true, - }, - ], - }])?; + tui::run(qs)?; } - Ok(()) })() .or_else(|e: Error| { diff --git a/src/stackexchange.rs b/src/stackexchange.rs index 8c65942..183e64f 100644 --- a/src/stackexchange.rs +++ b/src/stackexchange.rs @@ -1,4 +1,3 @@ -#![allow(dead_code, unused_imports, unused_mut, unused_variables)] use flate2::read::GzDecoder; use reqwest::blocking::Client; use reqwest::Url; @@ -25,6 +24,7 @@ const SE_SITES_PAGESIZE: u16 = 10000; /// This structure allows interacting with parts of the StackExchange /// API, using the `Config` struct to determine certain API settings and options. +// TODO should my se structs have &str instead of String? pub struct StackExchange { client: Client, config: Config, diff --git a/src/tui/app.rs b/src/tui/app.rs index b02da8c..c079828 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,24 +1,22 @@ -#![allow(dead_code, unused_imports, unused_mut, unused_variables)] -use crossterm::execute; -use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; -use std::io; -use std::io::Write; -use tui::backend::CrosstermBackend; -use tui::buffer::Buffer; -use tui::layout::{Alignment, Constraint, Direction, Layout as TuiLayout, Rect}; -use tui::style::Style; -use tui::widgets::{Block, Borders, Paragraph, Text, Widget}; -use tui::Terminal; +use cursive::views::TextView; +use super::markdown; use crate::error::Result; -use crate::stackexchange::{Answer, Question}; -use crate::tui::enumerable::Enum; -use crate::tui::list; +use crate::stackexchange::Question; + // ----------------------------------------- // |question title list|answer preview list| 1/3 // ----------------------------------------- // |question body |answer body | 2/3 // ----------------------------------------- +// TODO <shift+HJKL> moves layout boundaries +// TODO <hjkl> to move focus? at least for lists.. +// TODO <space> to cycle layout +// TODO <?> to bring up key mappings +// TODO query initial term size to choose initial layout + +// TODO Circular Focus handles layout & focus & stuff +// TODO these might be "layers" ? pub enum Layout { BothColumns, SingleColumn, @@ -26,6 +24,7 @@ pub enum Layout { } // Tab to cycle focus +// TODO use NamedView pub enum Focus { QuestionList, AnswerList, @@ -55,106 +54,81 @@ pub enum Mode { //} // TODO maybe a struct like Tui::new(stackexchange) creates App::new and impls tui.run()? - +// TODO views::SelectView? // TODO take async questions // TODO take the entire SE struct for future questions pub fn run(qs: Vec<Question>) -> Result<()> { - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - terminal.hide_cursor()?; - - //terminal.draw(|mut f| ui::draw(&mut f, &mut app))?; - terminal.draw(|mut f| { - let chunks = TuiLayout::default() - .direction(Direction::Horizontal) - .constraints( - [ - // TODO this depends on app.ratio and app.layout - Constraint::Ratio(1, 2), - Constraint::Ratio(1, 2), - ] - .as_ref(), - ) - .split(f.size()); - // TODO this depends on app.layout - let question_pane = TuiLayout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)].as_ref()) - .split(chunks[0]); - let answer_pane = TuiLayout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)].as_ref()) - .split(chunks[1]); - let block = Block::default().title("Questions").borders(Borders::ALL); - f.render_widget(block, question_pane[0]); - let block = Block::default().title("Answers").borders(Borders::ALL); - f.render_widget(block, answer_pane[0]); - // for now, just text - let t = qs[0].body.clone(); - let text = [Text::raw(t)]; - let p = Paragraph::new(text.iter()) - .block(Block::default().borders(Borders::ALL)) - .alignment(Alignment::Left) - .wrap(true); - f.render_widget(p, question_pane[1]); - let t = qs[0].answers[0].body.clone(); - let text = [Text::raw(t)]; - let p = Paragraph::new(text.iter()) - .block(Block::default().borders(Borders::ALL)) - .alignment(Alignment::Left) - .wrap(true); - f.render_widget(p, answer_pane[1]); - })?; - //disable_raw_mode()?; - std::thread::sleep(std::time::Duration::from_millis(10000)); - execute!(terminal.backend_mut(), LeaveAlternateScreen,)?; - terminal.show_cursor()?; + let mut siv = cursive::default(); + + //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(TextView::new(md)); + + siv.run(); Ok(()) } -//fmttext = termimad::FmtText::from(madskin, md, mwidth) -//// whose dispaly instance just calls -//for line in &self.lines { -//self.skin.write_fmt_line(f, line, self.width, false)?; -//writeln!(f)?; -//} - -////OR directly with madskin -//skin.write_in_area_on(w: Writer, md: &str, area: &Area) - -// lowest level -//skin.write_fmt_composite: actually applies styles to pieces of md text - -// little higher -//skin.write_fmt_line: also handles lines such as table borders - -// higher -//text.write_on: actually queues stuff up, cycling through its self.text.lines() and -//handling scrollbar - -// TODO shift HJKL moves layout boundaries -// TODO hjkl to move focus? at least for lists.. - -// TODO should my se structs have &str instead of String? - -// Space to cycle layout -// TODO query initial term size to init layout - -impl Enum for Layout { - fn to_enum(&self) -> u8 { - match self { - Layout::BothColumns => 0, - Layout::SingleColumn => 1, - Layout::FullScreen => 2, - } - } - fn from_enum(i: u8) -> Self { - match i % 3 { - 0 => Layout::BothColumns, - 1 => Layout::SingleColumn, - _ => Layout::FullScreen, - } +// TODO prettier and more valuable tests +#[cfg(test)] +mod tests { + use super::*; + use crate::stackexchange::{Answer, Question}; + #[test] + fn test_app() { + let ans_body = r#" +Also try the iter: +1. asdf +2. asfd +0. asdfa sfsdf + +but + + cargo build --example stderr + +and then you run it with + + cd "$(target/debug/examples/stderr)" + cd `(target/debug/examples/stderr)` + +what the application prints on stdout is used as argument to `cd`. + +Try it out. + +Hit any key to quit this screen: + +* **1** will print `..` +* **2** will print `/` +* **3** will print `~` +* or anything else to print this text (so that you may copy-paste) +"#; + let qs = vec![Question { + id: 42, + score: 323, + title: "How do I exit Vim?".to_string(), + body: "yo this be my problem dawg but don't say **do** `this`".to_string(), + answers: vec![ + Answer { + id: 422, + score: -4, + body: ans_body.to_string(), + is_accepted: false, + }, + Answer { + id: 423, + score: 23, + body: "this is a *good* answer tho".to_string(), + is_accepted: true, + }, + ], + }]; + + assert_eq!(run(qs).unwrap(), ()); } } diff --git a/src/tui/list.rs b/src/tui/list.rs deleted file mode 100644 index 81d992d..0000000 --- a/src/tui/list.rs +++ /dev/null @@ -1,60 +0,0 @@ -use tui::widgets::ListState; - -pub struct StatefulList<T> { - pub state: ListState, - pub items: Vec<T>, -} - -impl<T> StatefulList<T> { - pub fn new() -> StatefulList<T> { - StatefulList { - state: ListState::default(), - items: Vec::new(), - } - } - - pub fn with_items(items: Vec<T>) -> StatefulList<T> { - StatefulList { - state: ListState::default(), - items, - } - } - - pub fn next(&mut self) { - let i = match self.state.selected() { - Some(i) => { - if i >= self.items.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.state.select(Some(i)); - } - - pub fn previous(&mut self) { - let i = match self.state.selected() { - Some(i) => { - if i == 0 { - self.items.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - self.state.select(Some(i)); - } - - pub fn unselect(&mut self) { - self.state.select(None); - } -} - -impl<T> From<Vec<T>> for StatefulList<T> { - fn from(v: Vec<T>) -> Self { - StatefulList::with_items(v) - } -} diff --git a/src/tui/markdown.hs b/src/tui/markdown.hs deleted file mode 100644 index 3627d23..0000000 --- a/src/tui/markdown.hs +++ /dev/null @@ -1,3 +0,0 @@ -pub struct Markdown { - - diff --git a/src/tui/markdown.rs b/src/tui/markdown.rs index c3dbfc9..64d3141 100644 --- a/src/tui/markdown.rs +++ b/src/tui/markdown.rs @@ -1,135 +1,247 @@ -// possibly make this a separate package for others -pub struct Markdown {}; +//! Parse markdown text. +//! +//! Extended from cursive::utils::markup::markdown to add code styles +//! TODO: Bring in the full power of termimad (e.g. md tables) in a View; +//! implementation of those features (.e.g automatic wrapping within each table +//! cell) might be easier in this setting anyway. -// Below is Paragraph; adjust to Markdown! +use std::borrow::Cow; -// style <-> termimad::MadSkin +use cursive::theme::{Effect, PaletteColor, Style}; +use cursive::utils::markup::{StyledIndexedSpan, StyledString}; +use cursive::utils::span::IndexedCow; -// Wrap/Truncate <-> termimad::DisplayableLine -// and the stuff in termimad::composite{fit_width, extend_width} +use pulldown_cmark::{self, CowStr, Event, Tag}; +use unicode_width::UnicodeWidthStr; -// Looping over line_composer.next_line() <-> write_fmt_line -// (see also text_view.write_on) - -#[derive(Debug, Clone)] -pub struct Paragraph<'a, 't, T> +/// Parses the given string as markdown text. +pub fn parse<S>(input: S) -> StyledString where - T: Iterator<Item = &'t Text<'t>>, + S: Into<String>, { - /// A block to wrap the widget in - block: Option<Block<'a>>, - /// Widget style - style: Style, - /// Wrap the text or not - wrapping: bool, - /// The text to display - text: T, - /// Should we parse the text for embedded commands - raw: bool, - /// Scroll - scroll: u16, - /// Aligenment of the text - alignment: Alignment, -} + let input = input.into(); -impl<'a, 't, T> Paragraph<'a, 't, T> -where - T: Iterator<Item = &'t Text<'t>>, -{ - pub fn new(text: T) -> Paragraph<'a, 't, T> { - Paragraph { - block: None, - style: Default::default(), - wrapping: false, - raw: false, - text, - scroll: 0, - alignment: Alignment::Left, - } - } + let spans = parse_spans(&input); - pub fn block(mut self, block: Block<'a>) -> Paragraph<'a, 't, T> { - self.block = Some(block); - self - } + StyledString::with_spans(input, spans) +} - pub fn style(mut self, style: Style) -> Paragraph<'a, 't, T> { - self.style = style; - self - } +/// Iterator that parse a markdown text and outputs styled spans. +pub struct Parser<'a> { + first: bool, + item: Option<u64>, + in_list: bool, + stack: Vec<Style>, + input: &'a str, + parser: pulldown_cmark::Parser<'a>, +} - pub fn wrap(mut self, flag: bool) -> Paragraph<'a, 't, T> { - self.wrapping = flag; - self +impl<'a> Parser<'a> { + /// Creates a new parser with the given input text. + pub fn new(input: &'a str) -> Self { + Parser { + input, + item: None, + in_list: false, + first: true, + parser: pulldown_cmark::Parser::new(input), + stack: Vec::new(), + } } - pub fn raw(mut self, flag: bool) -> Paragraph<'a, 't, T> { - self.raw = flag; - self + /// Creates a new span with the given value + fn literal<S>(&self, text: S) -> StyledIndexedSpan + where + S: Into<String>, + { + StyledIndexedSpan::simple_owned(text.into(), Style::merge(&self.stack)) } - pub fn scroll(mut self, offset: u16) -> Paragraph<'a, 't, T> { - self.scroll = offset; - self + fn cow_to_span(&self, text: Cow<str>, style: Option<Style>) -> StyledIndexedSpan { + let width = text.width(); + StyledIndexedSpan { + content: IndexedCow::from_cow(text, self.input), + attr: style.unwrap_or_else(|| Style::merge(&self.stack)), + width, + } } - pub fn alignment(mut self, alignment: Alignment) -> Paragraph<'a, 't, T> { - self.alignment = alignment; - self + fn cowstr_to_span(&self, text: CowStr, style: Option<Style>) -> StyledIndexedSpan { + let text = match text { + CowStr::Boxed(text) => Cow::Owned(text.into()), + CowStr::Borrowed(text) => Cow::Borrowed(text), + CowStr::Inlined(text) => Cow::Owned(text.to_string()), + }; + self.cow_to_span(text, style) } } -impl<'a, 't, 'b, T> Widget for Paragraph<'a, 't, T> -where - T: Iterator<Item = &'t Text<'t>>, -{ - fn render(mut self, area: Rect, buf: &mut Buffer) { - let text_area = match self.block { - Some(ref mut b) => { - b.render(area, buf); - b.inner(area) +impl<'a> Iterator for Parser<'a> { + type Item = StyledIndexedSpan; + + fn next(&mut self) -> Option<Self::Item> { + loop { + let next = match self.parser.next() { + None => return None, + Some(event) => event, + }; + + // TODO fix list tag + match next { + Event::Start(tag) => match tag { + // Add to the stack! + Tag::Emphasis => self.stack.push(Style::from(Effect::Italic)), + Tag::Heading(level) if level == 1 => { + self.stack.push(Style::from(PaletteColor::TitlePrimary)) + } + Tag::Heading(_) => self.stack.push(Style::from(PaletteColor::TitleSecondary)), + // TODO style quote? + Tag::BlockQuote => return Some(self.literal("> ")), + Tag::Link(_, _, _) => return Some(self.literal("[")), + Tag::CodeBlock(_) => { + self.stack.push(Style::from(PaletteColor::Secondary)); + return Some(self.literal("\n\n")); + } + Tag::Strong => self.stack.push(Style::from(Effect::Bold)), + Tag::Paragraph if !self.first && !self.in_list => { + return Some(self.literal("\n\n")) + } + Tag::List(ix) => { + self.item = ix; + self.in_list = true; + return Some(self.literal("\n\n")); + } + Tag::Item => match self.item { + Some(ix) => { + let pre = ix.to_string() + ". "; + return Some( + self.cow_to_span(Cow::Owned(pre), Some(Style::from(Effect::Bold))), + ); + } + None => { + return Some(self.literal("• ".to_string())); + } + }, + _ => (), + }, + Event::End(tag) => match tag { + // Remove from stack! + Tag::Paragraph if self.first => self.first = false, + Tag::Heading(_) => { + self.stack.pop().unwrap(); + return Some(self.literal("\n\n")); + } + // TODO underline the link + Tag::Link(_, link, _) => return Some(self.literal(format!("]({})", link))), + Tag::CodeBlock(_) => { + self.stack.pop().unwrap(); + } + Tag::Emphasis | Tag::Strong => { + self.stack.pop().unwrap(); + } + Tag::List(_) => { + self.item = None; + self.in_list = false; + } + Tag::Item => { + self.item = self.item.map(|ix| ix + 1); + return Some(self.literal("\n")); + } + _ => (), + }, + Event::Rule => return Some(self.literal("---")), + Event::SoftBreak => return Some(self.literal("\n")), + Event::HardBreak => return Some(self.literal("\n")), + Event::Code(text) => { + return Some( + self.cowstr_to_span(text, Some(Style::from(PaletteColor::Secondary))), + ); + } + // Treat all other texts the same + Event::FootnoteReference(text) | Event::Html(text) | Event::Text(text) => { + return Some(self.cowstr_to_span(text, None)); + } + Event::TaskListMarker(checked) => { + let mark = if checked { "[x]" } else { "[ ]" }; + return Some(self.cow_to_span( + Cow::Owned(mark.to_string()), + Some(Style::from(Effect::Bold)), + )); + } } - None => area, - }; - - if text_area.height < 1 { - return; } + } +} - buf.set_background(text_area, self.style.bg); - - let style = self.style; - let mut styled = self.text.by_ref().flat_map(|t| match *t { - Text::Raw(ref d) => { - let data: &'t str = d; // coerce to &str - Either::Left(UnicodeSegmentation::graphemes(data, true).map(|g| Styled(g, style))) - } - Text::Styled(ref d, s) => { - let data: &'t str = d; // coerce to &str - Either::Right(UnicodeSegmentation::graphemes(data, true).map(move |g| Styled(g, s))) - } - }); +/// Parse the given markdown text into a list of spans. +/// +/// This is a shortcut for `Parser::new(input).collect()`. +pub fn parse_spans(input: &str) -> Vec<StyledIndexedSpan> { + Parser::new(input).collect() +} - let mut line_composer: Box<dyn LineComposer> = if self.wrapping { - Box::new(WordWrapper::new(&mut styled, text_area.width)) - } else { - Box::new(LineTruncator::new(&mut styled, text_area.width)) - }; - let mut y = 0; - while let Some((current_line, current_line_width)) = line_composer.next_line() { - if y >= self.scroll { - let mut x = get_line_offset(current_line_width, text_area.width, self.alignment); - for Styled(symbol, style) in current_line { - buf.get_mut(text_area.left() + x, text_area.top() + y - self.scroll) - .set_symbol(symbol) - .set_style(*style); - x += symbol.width() as u16; +// 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 +#[cfg(test)] +mod tests { + use super::*; + use cursive::utils::span::Span; + + #[test] + fn test_parse() { + let input = r" +Attention +==== +I *really* love __Cursive__!"; + let spans = parse_spans(input); + let spans: Vec<_> = spans.iter().map(|span| span.resolve(input)).collect(); + + // println!("{:?}", spans); + assert_eq!( + &spans[..], + &[ + Span { + content: "# ", + width: 2, + attr: &Style::none(), + }, + Span { + content: "Attention", + width: 9, + attr: &Style::none(), + }, + Span { + content: "\n\n", + width: 0, + attr: &Style::none(), + }, + Span { + content: "I ", + width: 2, + attr: &Style::none(), + }, + Span { + content: "really", + width: 6, + attr: &Style::from(Effect::Italic), + }, + Span { + content: " love ", + width: 6, + attr: &Style::none(), + }, + Span { + content: "Cursive", + width: 7, + attr: &Style::from(Effect::Bold), + }, + Span { + content: "!", + width: 1, + attr: &Style::none(), } - } - y += 1; - if y >= text_area.height + self.scroll { - break; - } - } + ] + ); } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f74434f..12d415a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,6 +1,6 @@ mod app; mod enumerable; -mod list; +mod markdown; mod ui; pub use app::run; |