forked from cadey/maj
majc: history support, link clicking support and more
This commit is contained in:
parent
37a9a1347c
commit
c3e20b089c
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "majc"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
authors = ["Christine Dodrill <me@christine.website>"]
|
||||
edition = "2018"
|
||||
|
||||
|
|
|
@ -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"),
|
||||
)));
|
||||
}
|
|
@ -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<url::Url>,
|
||||
history: Vec<url::Url>,
|
||||
links: Vec<(url::Url, Option<String>)>,
|
||||
}
|
||||
|
||||
pub fn history(siv: &mut Cursive) {
|
||||
let st = siv.user_data::<State>().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::<State>().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::<State>().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::<State>().unwrap();
|
||||
st.history.pop();
|
||||
open(siv, resp.meta.as_str());
|
||||
}
|
||||
|
||||
PermanentRedirect => {
|
||||
let st = siv.user_data::<State>().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::<State>().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::<State>().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
|
||||
}
|
|
@ -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
|
||||
<esc>: 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
|
||||
|
|
194
majc/src/main.rs
194
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
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
Loading…
Reference in New Issue