diff --git a/majc/Cargo.toml b/majc/Cargo.toml index e1546b6..5d19534 100644 --- a/majc/Cargo.toml +++ b/majc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "majc" -version = "0.1.0" +version = "0.2.0" authors = ["Christine Dodrill "] edition = "2018" diff --git a/majc/src/commands.rs b/majc/src/commands.rs new file mode 100644 index 0000000..a3752f2 --- /dev/null +++ b/majc/src/commands.rs @@ -0,0 +1,24 @@ +use cursive::{ + traits::*, + views::{Dialog, TextView}, + Cursive, +}; + +pub fn help(siv: &mut Cursive) { + let content = include_str!("./help.gmi"); + + siv.add_layer( + Dialog::around(TextView::new(crate::gemini::render(content)).scrollable()) + .title("Help") + .dismiss_button("Ok"), + ); +} + +pub fn about(siv: &mut Cursive) { + siv.add_layer(Dialog::info(format!( + "{} {}\n\nby {}\n\nSee https://tulpa.dev/cadey/maj for more information", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_AUTHORS"), + ))); +} diff --git a/majc/src/gemini.rs b/majc/src/gemini.rs new file mode 100644 index 0000000..2c527dd --- /dev/null +++ b/majc/src/gemini.rs @@ -0,0 +1,223 @@ +use cursive::{ + theme::{Effect, Style}, + traits::*, + utils::markup::StyledString, + views::{Dialog, EditView, ResizedView, SelectView, TextView}, + Cursive, +}; +use maj::{self, Response}; +use std::str; + +/// The state of the browser. +#[derive(Default, Clone, Debug)] +pub struct State { + url: Option, + history: Vec, + links: Vec<(url::Url, Option)>, +} + +pub fn history(siv: &mut Cursive) { + let st = siv.user_data::().unwrap(); + + let mut select = SelectView::new().autojump(); + + for entry in st.history.iter() { + select.add_item( + format!("{}{}", entry.host_str().unwrap(), entry.path()), + entry.to_string(), + ); + } + + select.set_on_submit(open); + + siv.add_layer( + Dialog::around(select.scrollable()) + .title("History") + .button("Cancel", |s| { + s.pop_layer(); + }), + ); +} + +pub fn links(siv: &mut Cursive) { + let st = siv.user_data::().unwrap(); + + let mut select = SelectView::new().autojump(); + + for link in st.links.iter() { + select.add_item( + match &link.1 { + None => link.0.to_string(), + Some(name) => format!("{}: {}", name, link.0.to_string()), + }, + link.0.to_string(), + ); + } + + select.set_on_submit(open); + + siv.add_layer( + Dialog::around(select.scrollable()) + .title("Links") + .button("Cancel", |s| { + s.pop_layer(); + }), + ); +} + +pub fn open_prompt(siv: &mut Cursive) { + siv.add_layer( + Dialog::around( + EditView::new() + .on_submit(open) + .with_name("url") + .fixed_width(50), + ) + .title("Enter a Gemini URL") + .button("Ok", |s| { + let url = s + .call_on_name("url", |view: &mut EditView| view.get_content()) + .unwrap(); + open(s, &url); + }) + .button("Cancel", |s| { + s.pop_layer(); + }), + ); +} + +pub fn open(siv: &mut Cursive, url: &str) { + siv.pop_layer(); + log::debug!("got URL: {}", url); + let mut st = siv.user_data::().unwrap(); + + let mut url: String = url.to_string(); + if !url.starts_with("gemini://") { + url = format!("gemini://{}", url); + } + + let url = url::Url::parse(url.as_str()).unwrap(); + + st.url = Some(url.clone()); + + match maj::get(url.to_string()) { + Ok(resp) => { + st.history.push(url.clone()); + show(siv, url.to_string().as_str(), resp); + } + Err(why) => { + log::error!("got response error: {:?}", why); + siv.add_layer(Dialog::info(format!("Error fetching response: {:?}", why))); + } + } +} + +pub fn show(siv: &mut Cursive, url: &str, resp: Response) { + use maj::StatusCode::*; + + match resp.status { + Success => match str::from_utf8(&resp.body) { + Ok(content) => { + let content: StyledString = if resp.meta.starts_with("text/gemini") { + populate_links(siv, content); + render(content) + } else { + StyledString::plain(content) + }; + siv.add_fullscreen_layer(ResizedView::with_full_screen( + Dialog::around(TextView::new(content).scrollable()) + .title(format!("{}: {}", url, resp.meta)), + )); + } + Err(why) => { + siv.add_layer(Dialog::info(format!( + "UTF/8 decoding error for {}: {:?}", + url, why + ))); + } + }, + + TemporaryRedirect => { + let st = siv.user_data::().unwrap(); + st.history.pop(); + open(siv, resp.meta.as_str()); + } + + PermanentRedirect => { + let st = siv.user_data::().unwrap(); + st.history.pop(); + open(siv, resp.meta.as_str()); + } + + Input => { + siv.add_layer( + Dialog::around(EditView::new().with_name("input").fixed_width(50)) + .title(resp.meta) + .button("Ok", |s| { + let inp = s + .call_on_name("input", |view: &mut EditView| view.get_content()) + .unwrap(); + let url = { + let st = s.user_data::().unwrap(); + let url = st.url.as_ref().unwrap(); + let mut u = url.clone(); + u.set_query(Some(&inp)); + &u.to_string() + }; + open(s, url); + }), + ); + } + + _ => { + siv.add_layer(Dialog::info(format!("{:?}: {}", resp.status, resp.meta))); + return; + } + } +} + +pub fn populate_links(siv: &mut Cursive, body: &str) { + use maj::gemini::Node::*; + let doc = maj::gemini::parse(body); + let st = siv.user_data::().unwrap(); + st.links.clear(); + let u = st.url.as_ref().unwrap(); + + for node in doc { + match node { + Link { to, name } => st.links.push((u.join(&to).unwrap(), name)), + _ => {} + } + } +} + +pub fn render(body: &str) -> StyledString { + let doc = maj::gemini::parse(body); + let mut styled = StyledString::new(); + + use maj::gemini::Node::*; + + for node in doc { + match node { + Text(line) => styled.append(StyledString::plain(line)), + Link { to, name } => { + match name { + None => styled.append(StyledString::styled(to, Style::from(Effect::Underline))), + Some(name) => { + styled.append(StyledString::styled(name, Style::from(Effect::Underline))) + } + } + } + Preformatted(data) => styled.append(StyledString::plain(data)), + Heading { level, body } => styled.append(StyledString::styled( + format!("{} {}", "#".repeat(level as usize), body), + Style::from(Effect::Bold), + )), + ListItem(item) => styled.append(StyledString::plain(format!("* {}", item))), + Quote(quote) => styled.append(StyledString::plain(format!("> {}", quote))), + } + styled.append(StyledString::plain("\n")); + } + + styled +} diff --git a/majc/src/help.gmi b/majc/src/help.gmi index 344cae7..80c3f74 100644 --- a/majc/src/help.gmi +++ b/majc/src/help.gmi @@ -1,26 +1,24 @@ -# majc ``` - __ - _____ _____ |__| ____ - / \ \__ \ | |_/ ___\ -| Y Y \ / __ \_ | |\ \___ -|__|_| /(____ //\__| | \___ > - \/ \/ \______| \/ + __ + _____ _____ |__| ____ + / \ \__ \ | |_/ ___\ + | Y Y \ / __ \_ | |\ \___ + |__|_| /(____ //\__| | \___ > + \/ \/ \______| \/ ``` A curses client for Gemini! -=> gemini://gemini.circumlunar.space/ Gemini homepage - ## Homepage The main homepage for majc is on tulpa.dev: - => https://tulpa.dev/cadey/maj ## Important Keys : opens the menubar c: closes the active window o: prompts to open a URL +h: shows history +l: shows active links in the page q: quits majc ?: shows this screen ~: toggles the debug logging pane diff --git a/majc/src/main.rs b/majc/src/main.rs index 7d4a42a..ded7bce 100644 --- a/majc/src/main.rs +++ b/majc/src/main.rs @@ -1,202 +1,44 @@ -use cursive::{ - event::Key, - menu::MenuTree, - theme::{BaseColor, BorderStyle, Color, Effect, PaletteColor, Style, Theme}, - traits::*, - utils::markup::StyledString, - views::{Dialog, EditView, Panel, ResizedView, TextView}, - Cursive, -}; -use maj::{self, Response, StatusCode}; -use std::str; +use cursive::{event::Key, menu::MenuTree}; + +pub(crate) mod commands; +pub(crate) mod gemini; +pub(crate) mod theme; fn main() { cursive::logger::init(); let mut siv = cursive::default(); - let theme = custom_theme_from_cursive(&siv); - siv.set_theme(theme); + theme::apply(&mut siv); + + siv.set_user_data(gemini::State::default()); siv.add_global_callback('c', |s| { s.pop_layer(); }); siv.add_global_callback('q', cursive::Cursive::quit); siv.add_global_callback('~', cursive::Cursive::toggle_debug_console); - siv.add_global_callback('o', open_prompt); - siv.add_global_callback('?', help); + siv.add_global_callback('h', gemini::history); + siv.add_global_callback('l', gemini::links); + siv.add_global_callback('o', gemini::open_prompt); + siv.add_global_callback('?', commands::help); siv.menubar() .add_subtree( "majc", MenuTree::new() - .leaf("About", move |s| { - s.add_layer(Dialog::info(format!( - "{} {}\n\nby {}\n\nSee https://tulpa.dev/cadey/maj for more information", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION"), - env!("CARGO_PKG_AUTHORS"), - ))); - }) - .leaf("Help", help) + .leaf("About", commands::about) + .leaf("Help", commands::help) .leaf("Quit", cursive::Cursive::quit), ) - .add_leaf("Open", |s| open_prompt(s)); + .add_leaf("Open", gemini::open_prompt) + .add_leaf("Links", gemini::links) + .add_leaf("History", gemini::history); siv.set_autohide_menu(false); siv.add_global_callback(Key::Esc, |s| s.select_menubar()); - help(&mut siv); + commands::help(&mut siv); siv.run(); } - -fn help(siv: &mut Cursive) { - let content = include_str!("./help.gmi"); - - siv.add_layer( - Dialog::around(Panel::new( - TextView::new(render_gemini(content)).scrollable(), - )) - .title("Help") - .dismiss_button("Ok"), - ); -} - -fn open_prompt(siv: &mut Cursive) { - siv.add_layer( - Dialog::around( - EditView::new() - .on_submit(open) - .with_name("url") - .fixed_width(50), - ) - .title("Enter a Gemini URL") - .button("Ok", |s| { - let url = s - .call_on_name("url", |view: &mut EditView| view.get_content()) - .unwrap(); - open(s, &url); - }) - .button("Cancel", |s| { - s.pop_layer(); - }), - ); -} - -fn open(siv: &mut Cursive, url: &str) { - siv.pop_layer(); - log::debug!("got URL: {}", url); - - match maj::get(url.to_string()) { - Ok(resp) => { - show(siv, url, resp); - } - Err(why) => { - log::error!("got response error: {:?}", why); - siv.add_layer(Dialog::info(format!("Error fetching response: {:?}", why))); - } - } -} - -fn show(siv: &mut Cursive, url: &str, resp: Response) { - use StatusCode::*; - - match resp.status { - Success => match str::from_utf8(&resp.body) { - Ok(content) => { - let content: StyledString = if resp.meta.starts_with("text/gemini") { - render_gemini(content) - } else { - StyledString::plain(content) - }; - siv.add_fullscreen_layer(ResizedView::with_full_screen( - Dialog::around(TextView::new(content).scrollable()) - .title(format!("{}: {}", url, resp.meta)), - )); - } - Err(why) => { - siv.add_layer(Dialog::info(format!( - "UTF/8 decoding error for {}: {:?}", - url, why - ))); - } - }, - - TemporaryRedirect => { - open(siv, resp.meta.as_str()); - } - - PermanentRedirect => { - open(siv, resp.meta.as_str()); - } - - Input => { - siv.add_layer(Dialog::info("needs input support")); - return; - // siv.add_layer( - // Dialog::around(EditView::new().with_name("input").fixed_width(50)) - // .title(resp.meta) - // .button("Ok", |s| { - // let inp = s - // .call_on_name("input", |view: &mut EditView| view.get_content()) - // .unwrap(); - // let url = { - // let mut u = url::Url::parse(url).unwrap(); - // u.set_query(Some(&inp)); - // u.as_str() - // }; - // open(s, url); - // }), - // ); - } - - _ => { - siv.add_layer(Dialog::info(format!("{:?}: {}", resp.status, resp.meta))); - return; - } - } -} - -fn render_gemini(body: &str) -> StyledString { - let doc = maj::gemini::parse(body); - let mut styled = StyledString::new(); - - use maj::gemini::Node::*; - - for node in doc { - match node { - Text(line) => styled.append(StyledString::plain(line)), - Link { to, name } => match name { - None => styled.append(StyledString::styled(to, Style::from(Effect::Underline))), - Some(name) => { - styled.append(StyledString::styled(name, Style::from(Effect::Underline))) - } - }, - Preformatted(data) => styled.append(StyledString::plain(data)), - Heading { level: _, body } => { - styled.append(StyledString::styled(body, Style::from(Effect::Bold))) - } - ListItem(item) => styled.append(StyledString::plain(format!("* {}", item))), - Quote(quote) => styled.append(StyledString::plain(format!("> {}", quote))), - } - styled.append(StyledString::plain("\n")); - } - - styled -} - -fn custom_theme_from_cursive(siv: &Cursive) -> Theme { - // We'll return the current theme with a small modification. - let mut theme = siv.current_theme().clone(); - - theme.palette[PaletteColor::Background] = Color::TerminalDefault; - theme.palette[PaletteColor::View] = Color::TerminalDefault; - theme.palette[PaletteColor::Primary] = Color::Dark(BaseColor::White); - theme.palette[PaletteColor::TitlePrimary] = Color::Light(BaseColor::White); - - theme.shadow = false; - theme.borders = BorderStyle::Simple; - - theme -} diff --git a/majc/src/theme.rs b/majc/src/theme.rs new file mode 100644 index 0000000..762bd26 --- /dev/null +++ b/majc/src/theme.rs @@ -0,0 +1,19 @@ +use cursive::{ + theme::{BaseColor, BorderStyle, Color, PaletteColor}, + Cursive, +}; + +pub fn apply(siv: &mut Cursive) { + // We'll return the current theme with a small modification. + let mut theme = siv.current_theme().clone(); + + theme.palette[PaletteColor::Background] = Color::TerminalDefault; + theme.palette[PaletteColor::View] = Color::TerminalDefault; + theme.palette[PaletteColor::Primary] = Color::Dark(BaseColor::White); + theme.palette[PaletteColor::TitlePrimary] = Color::Light(BaseColor::White); + + theme.shadow = false; + theme.borders = BorderStyle::Simple; + + siv.set_theme(theme); +}