implement titan header options

This commit is contained in:
yggverse 2025-02-06 17:24:54 +02:00
parent 6267691af2
commit f6fb73c241
11 changed files with 289 additions and 25 deletions

View file

@ -110,7 +110,9 @@ GTK 4 / Libadwaita client written in Rust
* [ ] [Titan](https://transjovian.org/titan/page/The%20Titan%20Specification)
* [ ] Binary data (file uploads)
* [x] Text input
* [ ] Custom headers
* [x] Header options
* [x] MIME
* [x] Token
* [ ] [NEX](https://nightfall.city/nex/info/specification.txt) - useful for networks with build-in encryption (e.g. [Yggdrasil](https://yggdrasil-network.github.io))
* [ ] [NPS](https://nightfall.city/nps/info/specification.txt)
* [ ] System

View file

@ -7,7 +7,6 @@ use ggemini::{
client::{Client, Request, Response},
gio::{file_output_stream, memory_input_stream},
};
use gtk::glib::Bytes;
use gtk::glib::GString;
use gtk::{
gdk::Texture,
@ -85,14 +84,13 @@ impl Gemini {
let client = self.client.clone();
let page = self.page.clone();
let redirects = self.redirects.clone();
move |data, on_failure| {
move |header, bytes, on_failure| {
handle(
Request::Titan {
uri: uri.clone(),
data: Bytes::from(data),
// * some servers may reject the request without content type
mime: Some("text/plain".to_string()),
token: None, // @TODO
data: bytes,
mime: header.mime.map(|mime| mime.into()),
token: header.token.map(|token| token.into()),
},
client.clone(),
page.clone(),

View file

@ -5,7 +5,7 @@ mod titan;
use super::ItemAction;
use adw::Clamp;
use gtk::{
glib::Uri,
glib::{Bytes, Uri},
prelude::{IsA, WidgetExt},
Widget,
};
@ -74,7 +74,7 @@ impl Input {
self.update(Some(&gtk::Box::sensitive(action, base, title, max_length)));
}
pub fn set_new_titan(&self, on_send: impl Fn(&[u8], Box<dyn Fn()>) + 'static) {
pub fn set_new_titan(&self, on_send: impl Fn(titan::Header, Bytes, Box<dyn Fn()>) + 'static) {
self.update(Some(&gtk::Notebook::titan(on_send)));
}
}

View file

@ -1,25 +1,35 @@
mod file;
mod header;
mod text;
mod title;
use file::File;
use gtk::{glib::uuid_string_random, prelude::WidgetExt, Label, Notebook};
use gtk::{
glib::{uuid_string_random, Bytes},
Label, Notebook,
};
pub use header::Header;
use text::Text;
use title::Title;
pub trait Titan {
fn titan(callback: impl Fn(&[u8], Box<dyn Fn()>) + 'static) -> Self;
fn titan(callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static) -> Self;
}
impl Titan for Notebook {
fn titan(callback: impl Fn(&[u8], Box<dyn Fn()>) + 'static) -> Self {
fn titan(callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static) -> Self {
use gtk::Box;
use std::{cell::Cell, rc::Rc};
let notebook = Notebook::builder()
.name(format!("s{}", uuid_string_random()))
.show_border(false)
.build();
notebook.append_page(&gtk::Box::text(callback), Some(&Label::title("Text")));
notebook.append_page(&gtk::Box::file(), Some(&Label::title("File")));
let header = Rc::new(Cell::new(Header::new()));
notebook.append_page(&Box::text(&header, callback), Some(&Label::title("Text")));
notebook.append_page(&Box::file(), Some(&Label::title("File")));
notebook_css_patch(&notebook);
notebook
@ -29,6 +39,8 @@ impl Titan for Notebook {
// Tools
fn notebook_css_patch(notebook: &Notebook) {
use gtk::prelude::WidgetExt;
let name = notebook.widget_name();
let provider = gtk::CssProvider::new();

View file

@ -0,0 +1,75 @@
mod form;
use gtk::{glib::GString, prelude::IsA, Widget};
#[derive(Default)]
pub struct Header {
pub mime: Option<GString>,
pub token: Option<GString>,
}
impl Header {
pub fn new() -> Self {
Self {
mime: None,
token: None,
}
}
/// Show header options dialog for the referrer `widget`
/// * takes ownership of `Self`, return new updated copy in `callback` function
pub fn dialog(self, widget: Option<&impl IsA<Widget>>, callback: impl Fn(Self) + 'static) {
use adw::{
prelude::{AdwDialogExt, AlertDialogExt, AlertDialogExtManual},
AlertDialog, ResponseAppearance,
};
use form::Form;
use std::rc::Rc;
// Response variants
const RESPONSE_APPLY: (&str, &str) = ("apply", "Apply");
const RESPONSE_CANCEL: (&str, &str) = ("cancel", "Cancel");
// Init form components
let form = Rc::new(Form::build(
&self.mime.unwrap_or_default(),
&self.token.unwrap_or_default(),
));
// Init main widget
let alert_dialog = AlertDialog::builder()
.heading("Header")
.body("Custom header options")
.close_response(RESPONSE_CANCEL.0)
.default_response(RESPONSE_APPLY.0)
.extra_child(&form.g_box)
.build();
alert_dialog.add_responses(&[RESPONSE_CANCEL, RESPONSE_APPLY]);
// Decorate default response preset
alert_dialog.set_response_appearance(RESPONSE_APPLY.0, ResponseAppearance::Suggested);
/* contrast issue with Ubuntu orange accents
alert_dialog.set_response_appearance(RESPONSE_CANCEL.0, ResponseAppearance::Destructive); */
// Init events
alert_dialog.connect_response(None, {
let form = form.clone();
move |this, response| {
this.set_response_enabled(response, false); // prevent double-click
if response == RESPONSE_APPLY.0 {
callback(Self {
mime: form.mime(),
token: form.token(),
})
} else {
// @TODO restore
}
}
});
// Show
alert_dialog.present(widget);
}
}

View file

@ -0,0 +1,56 @@
mod mime;
mod token;
use mime::Mime;
use token::Token;
use gtk::{
glib::GString,
prelude::{BoxExt, EditableExt},
Box, Entry, Orientation,
};
pub struct Form {
pub g_box: Box,
mime: Entry,
token: Entry,
}
impl Form {
// Constructors
pub fn build(mime_value: &str, token_value: &str) -> Self {
// Init components
let mime = Entry::mime(mime_value);
let token = Entry::token(token_value);
// Init `Self`
let g_box = Box::builder().orientation(Orientation::Vertical).build();
g_box.append(&mime);
g_box.append(&token);
Self { g_box, mime, token }
}
// Getters
pub fn mime(&self) -> Option<GString> {
value(&self.mime)
}
pub fn token(&self) -> Option<GString> {
value(&self.token)
}
}
// Tools
fn value(label: &Entry) -> Option<GString> {
let text = label.text();
if !text.is_empty() {
Some(text)
} else {
None
}
}

View file

@ -0,0 +1,44 @@
pub trait Mime {
fn mime(text: &str) -> Self;
fn validate(&self);
}
impl Mime for gtk::Entry {
fn mime(text: &str) -> Self {
use gtk::prelude::EditableExt;
let mime = gtk::Entry::builder()
.placeholder_text("Content type (MIME)")
.margin_bottom(8)
.text(text)
.build();
mime.connect_changed(|this| {
this.validate();
});
mime
}
fn validate(&self) {
use gtk::prelude::{EditableExt, WidgetExt};
const CLASS: (&str, &str) = ("error", "success");
self.remove_css_class(CLASS.0);
self.remove_css_class(CLASS.1);
if !self.text().is_empty() {
if gtk::glib::Regex::match_simple(
r"^\w+/\w+$",
self.text(),
gtk::glib::RegexCompileFlags::DEFAULT,
gtk::glib::RegexMatchFlags::DEFAULT,
) {
self.add_css_class(CLASS.1)
} else {
self.add_css_class(CLASS.0)
}
}
}
}

View file

@ -0,0 +1,14 @@
use gtk::Entry;
pub trait Token {
fn token(text: &str) -> Self;
}
impl Token for Entry {
fn token(text: &str) -> Self {
Entry::builder()
.placeholder_text("Token")
.text(text)
.build()
}
}

View file

@ -1,28 +1,39 @@
mod control;
mod form;
use super::Header;
use control::Control;
use control::Upload;
use form::Form;
use gtk::glib::Bytes;
use gtk::{
prelude::{BoxExt, ButtonExt, TextBufferExt, TextViewExt},
Orientation, TextView,
};
use std::cell::Cell;
use std::rc::Rc;
const MARGIN: i32 = 8;
const SPACING: i32 = 8;
pub trait Text {
fn text(callback: impl Fn(&[u8], Box<dyn Fn()>) + 'static) -> Self;
fn text(
header: &Rc<Cell<Header>>,
callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static,
) -> Self;
}
impl Text for gtk::Box {
fn text(callback: impl Fn(&[u8], Box<dyn Fn()>) + 'static) -> Self {
fn text(
header: &Rc<Cell<Header>>,
callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static,
) -> Self {
// Init components
let control = Rc::new(Control::build());
let control = Rc::new(Control::build(header));
let form = TextView::form();
//header.take().dialog();
// Init widget
let g_box = gtk::Box::builder()
.margin_bottom(MARGIN)
@ -37,12 +48,28 @@ impl Text for gtk::Box {
g_box.append(&control.g_box);
// Connect events
form.buffer().connect_changed({
let control = control.clone();
let form = form.clone();
move |this| control.update(this.char_count(), form.text().len())
});
control.upload.connect_clicked({
let form = form.clone();
let header = header.clone();
move |this| {
this.set_uploading();
let header = header.take();
callback(
form.text().as_bytes(),
Header {
mime: match header.mime {
Some(mime) => Some(mime),
None => Some("text/plain".into()), // server may reject the request without content type
},
token: header.token,
},
Bytes::from(form.text().as_bytes()),
Box::new({
let this = this.clone();
move || this.set_resend() // on failure
@ -51,12 +78,6 @@ impl Text for gtk::Box {
}
});
form.buffer().connect_changed({
let control = control.clone();
let form = form.clone();
move |this| control.update(this.char_count(), form.text().len())
});
g_box
}
}

View file

@ -1,11 +1,15 @@
mod counter;
mod options;
mod upload;
use super::Header;
use counter::Counter;
use gtk::{
prelude::{BoxExt, WidgetExt},
Align, Box, Button, Label, Orientation,
};
use options::Options;
use std::{cell::Cell, rc::Rc};
pub use upload::Upload;
const SPACING: i32 = 8;
@ -20,9 +24,10 @@ impl Control {
// Constructors
/// Build new `Self`
pub fn build() -> Self {
pub fn build(header: &Rc<Cell<Header>>) -> Self {
// Init components
let counter = Label::counter();
let options = Button::options(header);
let upload = Button::upload();
// Init main widget
@ -33,6 +38,7 @@ impl Control {
.build();
g_box.append(&counter);
g_box.append(&options);
g_box.append(&upload);
// Return activated struct

View file

@ -0,0 +1,36 @@
use super::Header;
use gtk::{
prelude::{ButtonExt, WidgetExt},
Button,
};
use std::{cell::Cell, rc::Rc};
pub trait Options {
fn options(header: &Rc<Cell<Header>>) -> Self;
}
impl Options for Button {
fn options(header: &Rc<Cell<Header>>) -> Self {
let button = Button::builder()
.icon_name("emblem-system-symbolic")
.tooltip_text("Options")
.build();
button.connect_clicked({
let header = header.clone();
move |this| {
this.set_sensitive(false); // lock
header.take().dialog(Some(this), {
let this = this.clone();
let header = header.clone();
move |options| {
header.replace(options);
this.set_sensitive(true); // unlock
}
})
}
});
button
}
}