mirror of
https://github.com/YGGverse/Yoda.git
synced 2026-03-31 16:45:27 +00:00
implement text/nex renderer
This commit is contained in:
parent
02eb8e4a71
commit
337bf32707
7 changed files with 349 additions and 6 deletions
|
|
@ -130,7 +130,7 @@ GTK 4 / Libadwaita client written in Rust
|
||||||
#### Text
|
#### Text
|
||||||
* [x] `text/gemini`
|
* [x] `text/gemini`
|
||||||
* [x] `text/plain`
|
* [x] `text/plain`
|
||||||
* [ ] `text/nex`
|
* [x] `text/nex`
|
||||||
|
|
||||||
#### Images
|
#### Images
|
||||||
* [x] `image/gif`
|
* [x] `image/gif`
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ fn render(
|
||||||
} else if q.ends_with(".gmi") || q.ends_with(".gemini") {
|
} else if q.ends_with(".gmi") || q.ends_with(".gemini") {
|
||||||
p.content.to_text_gemini(&u, d)
|
p.content.to_text_gemini(&u, d)
|
||||||
} else {
|
} else {
|
||||||
p.content.to_text_plain(d)
|
p.content.to_text_nex(&u, d)
|
||||||
};
|
};
|
||||||
event(&p, "Parsed", Some(s));
|
event(&p, "Parsed", Some(s));
|
||||||
p.search.set(Some(t.text_view));
|
p.search.set(Some(t.text_view));
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,14 @@ impl Content {
|
||||||
text
|
text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [text/nex](https://nightfall.city/nex/info/specification.txt)
|
||||||
|
pub fn to_text_nex(&self, base: &Uri, data: &str) -> Text {
|
||||||
|
self.clean();
|
||||||
|
let text = Text::nex((&self.window_action, &self.item_action), base, data);
|
||||||
|
self.g_box.append(&text.scrolled_window);
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
pub fn to_directory(
|
pub fn to_directory(
|
||||||
&self,
|
&self,
|
||||||
file: &File,
|
file: &File,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod gemini;
|
mod gemini;
|
||||||
|
mod nex;
|
||||||
mod plain;
|
mod plain;
|
||||||
mod source;
|
mod source;
|
||||||
|
|
||||||
|
|
@ -6,6 +7,7 @@ use super::{ItemAction, WindowAction};
|
||||||
use adw::ClampScrollable;
|
use adw::ClampScrollable;
|
||||||
use gemini::Gemini;
|
use gemini::Gemini;
|
||||||
use gtk::{ScrolledWindow, TextView, glib::Uri};
|
use gtk::{ScrolledWindow, TextView, glib::Uri};
|
||||||
|
use nex::Nex;
|
||||||
use plain::Plain;
|
use plain::Plain;
|
||||||
use source::Source;
|
use source::Source;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
@ -58,6 +60,15 @@ impl Text {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn nex(actions: (&Rc<WindowAction>, &Rc<ItemAction>), base: &Uri, data: &str) -> Self {
|
||||||
|
let text_view = TextView::nex(actions, base, data);
|
||||||
|
Self {
|
||||||
|
scrolled_window: reader(&text_view),
|
||||||
|
text_view,
|
||||||
|
meta: Meta { title: None },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn source(data: &str) -> Self {
|
pub fn source(data: &str) -> Self {
|
||||||
let source = sourceview::View::source(data);
|
let source = sourceview::View::source(data);
|
||||||
Self {
|
Self {
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,7 @@ impl Gemini {
|
||||||
}
|
}
|
||||||
|
|
||||||
alt.push(match link.alt {
|
alt.push(match link.alt {
|
||||||
Some(alt) => alt.to_string(),
|
Some(alt) => alt,
|
||||||
None => uri.to_string(),
|
None => uri.to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -236,7 +236,7 @@ impl Gemini {
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
if !tag.text_tag_table.add(&a) {
|
if !tag.text_tag_table.add(&a) {
|
||||||
todo!()
|
panic!()
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]);
|
buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]);
|
||||||
|
|
@ -315,7 +315,7 @@ impl Gemini {
|
||||||
if let Some(uri) = links.get(&tag) {
|
if let Some(uri) = links.get(&tag) {
|
||||||
// Select link handler by scheme
|
// Select link handler by scheme
|
||||||
return match uri.scheme().as_str() {
|
return match uri.scheme().as_str() {
|
||||||
"gemini" | "titan" | "file" => {
|
"gemini" | "titan" | "nex" | "file" => {
|
||||||
item_action.load.activate(Some(&uri.to_str()), true, false)
|
item_action.load.activate(Some(&uri.to_str()), true, false)
|
||||||
}
|
}
|
||||||
// Scheme not supported, delegate
|
// Scheme not supported, delegate
|
||||||
|
|
@ -352,7 +352,7 @@ impl Gemini {
|
||||||
if let Some(uri) = links.get(&tag) {
|
if let Some(uri) = links.get(&tag) {
|
||||||
// Select link handler by scheme
|
// Select link handler by scheme
|
||||||
return match uri.scheme().as_str() {
|
return match uri.scheme().as_str() {
|
||||||
"gemini" | "titan" | "file" => {
|
"gemini" | "titan" | "nex" | "file" => {
|
||||||
// Open new page in browser
|
// Open new page in browser
|
||||||
window_action.append.activate_stateful_once(
|
window_action.append.activate_stateful_once(
|
||||||
Position::After,
|
Position::After,
|
||||||
|
|
|
||||||
256
src/app/browser/window/tab/item/page/content/text/nex.rs
Normal file
256
src/app/browser/window/tab/item/page/content/text/nex.rs
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
mod gutter;
|
||||||
|
|
||||||
|
use super::{ItemAction, WindowAction};
|
||||||
|
use crate::app::browser::window::action::Position;
|
||||||
|
use gtk::{
|
||||||
|
EventControllerMotion, GestureClick, TextBuffer, TextTag, TextTagTable, TextView,
|
||||||
|
TextWindowType, UriLauncher, Window, WrapMode,
|
||||||
|
gdk::RGBA,
|
||||||
|
gio::Cancellable,
|
||||||
|
glib::Uri,
|
||||||
|
prelude::{TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt},
|
||||||
|
};
|
||||||
|
use gutter::Gutter;
|
||||||
|
use std::{cell::Cell, collections::HashMap, rc::Rc};
|
||||||
|
|
||||||
|
pub trait Nex {
|
||||||
|
fn nex(actions: (&Rc<WindowAction>, &Rc<ItemAction>), base: &Uri, data: &str) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Nex for TextView {
|
||||||
|
fn nex(
|
||||||
|
(window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>),
|
||||||
|
base: &Uri,
|
||||||
|
data: &str,
|
||||||
|
) -> Self {
|
||||||
|
pub const NEW_LINE: &str = "\n";
|
||||||
|
|
||||||
|
// Init tags
|
||||||
|
let tags = TextTagTable::new();
|
||||||
|
|
||||||
|
// Define default tag once
|
||||||
|
let plain_text_tag = TextTag::builder().wrap_mode(WrapMode::Word).build();
|
||||||
|
tags.add(&plain_text_tag);
|
||||||
|
|
||||||
|
// Init HashMap storage (for event controllers)
|
||||||
|
let mut links: HashMap<TextTag, Uri> = HashMap::new();
|
||||||
|
|
||||||
|
// Init hovered tag storage for `links`
|
||||||
|
// * maybe less expensive than update entire HashMap by iter
|
||||||
|
let hover: Rc<Cell<Option<TextTag>>> = Rc::new(Cell::new(None));
|
||||||
|
|
||||||
|
// Init colors
|
||||||
|
// @TODO use accent colors in adw 1.6 / ubuntu 24.10+
|
||||||
|
let link_color = (
|
||||||
|
RGBA::new(0.208, 0.518, 0.894, 1.0),
|
||||||
|
RGBA::new(0.208, 0.518, 0.894, 0.9),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Init new text buffer
|
||||||
|
let buffer = TextBuffer::new(Some(&tags));
|
||||||
|
|
||||||
|
// Collect links
|
||||||
|
for line in data.lines() {
|
||||||
|
// just borrow ggemtext parser as compatible API
|
||||||
|
if let Some(link) = ggemtext::line::Link::parse(line) {
|
||||||
|
if let Some(uri) = link.uri(Some(base)) {
|
||||||
|
let mut alt = Vec::new();
|
||||||
|
|
||||||
|
if uri.scheme() != base.scheme() {
|
||||||
|
alt.push("⇖".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
alt.push(match link.alt {
|
||||||
|
Some(alt) => alt,
|
||||||
|
None => uri.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let a = TextTag::builder()
|
||||||
|
.foreground_rgba(&link_color.0)
|
||||||
|
// .foreground_rgba(&adw::StyleManager::default().accent_color_rgba()) @TODO adw 1.6 / ubuntu 24.10+
|
||||||
|
.sentence(true)
|
||||||
|
.wrap_mode(WrapMode::Word)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if !tags.add(&a) {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]);
|
||||||
|
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||||
|
|
||||||
|
links.insert(a, uri);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing match custom tags above,
|
||||||
|
// just append plain text covered in empty tag (to handle controller events properly)
|
||||||
|
buffer.insert_with_tags(&mut buffer.end_iter(), line, &[&plain_text_tag]);
|
||||||
|
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init main widget
|
||||||
|
|
||||||
|
let text_view = {
|
||||||
|
const MARGIN: i32 = 8;
|
||||||
|
TextView::builder()
|
||||||
|
.bottom_margin(MARGIN)
|
||||||
|
.buffer(&buffer)
|
||||||
|
.cursor_visible(false)
|
||||||
|
.editable(false)
|
||||||
|
.left_margin(MARGIN)
|
||||||
|
.monospace(true)
|
||||||
|
.right_margin(MARGIN)
|
||||||
|
.top_margin(MARGIN)
|
||||||
|
.vexpand(true)
|
||||||
|
.wrap_mode(gtk::WrapMode::Word)
|
||||||
|
.build()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Init additional controllers
|
||||||
|
text_view.add_controller({
|
||||||
|
let c = GestureClick::builder()
|
||||||
|
.button(gtk::gdk::BUTTON_PRIMARY)
|
||||||
|
.build();
|
||||||
|
c.connect_released({
|
||||||
|
let item_action = item_action.clone();
|
||||||
|
let links = links.clone();
|
||||||
|
let text_view = text_view.clone();
|
||||||
|
move |_, _, window_x, window_y| {
|
||||||
|
// Detect tag match current coords hovered
|
||||||
|
let (buffer_x, buffer_y) = text_view.window_to_buffer_coords(
|
||||||
|
TextWindowType::Widget,
|
||||||
|
window_x as i32,
|
||||||
|
window_y as i32,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
||||||
|
for tag in iter.tags() {
|
||||||
|
// Tag is link
|
||||||
|
if let Some(uri) = links.get(&tag) {
|
||||||
|
// Select link handler by scheme
|
||||||
|
return match uri.scheme().as_str() {
|
||||||
|
"gemini" | "titan" | "nex" | "file" => {
|
||||||
|
item_action.load.activate(Some(&uri.to_str()), true, false)
|
||||||
|
}
|
||||||
|
// Scheme not supported, delegate
|
||||||
|
_ => UriLauncher::new(&uri.to_str()).launch(
|
||||||
|
Window::NONE,
|
||||||
|
Cancellable::NONE,
|
||||||
|
|r| {
|
||||||
|
if let Err(e) = r {
|
||||||
|
println!("{e}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}; // @TODO common handler?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
c
|
||||||
|
});
|
||||||
|
|
||||||
|
text_view.add_controller({
|
||||||
|
let c = GestureClick::builder()
|
||||||
|
.button(gtk::gdk::BUTTON_MIDDLE)
|
||||||
|
.build();
|
||||||
|
c.connect_pressed({
|
||||||
|
let links = links.clone();
|
||||||
|
let text_view = text_view.clone();
|
||||||
|
let window_action = window_action.clone();
|
||||||
|
move |_, _, window_x, window_y| {
|
||||||
|
// Detect tag match current coords hovered
|
||||||
|
let (buffer_x, buffer_y) = text_view.window_to_buffer_coords(
|
||||||
|
TextWindowType::Widget,
|
||||||
|
window_x as i32,
|
||||||
|
window_y as i32,
|
||||||
|
);
|
||||||
|
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
||||||
|
for tag in iter.tags() {
|
||||||
|
// Tag is link
|
||||||
|
if let Some(uri) = links.get(&tag) {
|
||||||
|
// Select link handler by scheme
|
||||||
|
return match uri.scheme().as_str() {
|
||||||
|
"gemini" | "titan" | "nex" | "file" => {
|
||||||
|
// Open new page in browser
|
||||||
|
window_action.append.activate_stateful_once(
|
||||||
|
Position::After,
|
||||||
|
Some(uri.to_string()),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Scheme not supported, delegate
|
||||||
|
_ => UriLauncher::new(&uri.to_str()).launch(
|
||||||
|
Window::NONE,
|
||||||
|
Cancellable::NONE,
|
||||||
|
|r| {
|
||||||
|
if let Err(e) = r {
|
||||||
|
println!("{e}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}; // @TODO common handler?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}); // for a note: this action sensitive to focus out
|
||||||
|
c
|
||||||
|
});
|
||||||
|
|
||||||
|
text_view.add_controller({
|
||||||
|
// Init gutter widget (the tooltip on URL tags hover)
|
||||||
|
let g = Gutter::build(&text_view);
|
||||||
|
let c = EventControllerMotion::new();
|
||||||
|
c.connect_motion({
|
||||||
|
let text_view = text_view.clone();
|
||||||
|
let links = links.clone();
|
||||||
|
let hover = hover.clone();
|
||||||
|
move |_, window_x, window_y| {
|
||||||
|
// Detect tag match current coords hovered
|
||||||
|
let (buffer_x, buffer_y) = text_view.window_to_buffer_coords(
|
||||||
|
TextWindowType::Widget,
|
||||||
|
window_x as i32,
|
||||||
|
window_y as i32,
|
||||||
|
);
|
||||||
|
// Reset link colors to default
|
||||||
|
if let Some(tag) = hover.replace(None) {
|
||||||
|
tag.set_foreground_rgba(Some(&link_color.0));
|
||||||
|
}
|
||||||
|
// Apply hover effect
|
||||||
|
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
|
||||||
|
for tag in iter.tags() {
|
||||||
|
// Tag is link
|
||||||
|
if let Some(uri) = links.get(&tag) {
|
||||||
|
// Toggle color
|
||||||
|
tag.set_foreground_rgba(Some(&link_color.1));
|
||||||
|
// Keep hovered tag in memory
|
||||||
|
hover.replace(Some(tag.clone()));
|
||||||
|
// Show tooltip
|
||||||
|
g.set_uri(Some(uri));
|
||||||
|
// Toggle cursor
|
||||||
|
text_view.set_cursor_from_name(Some("pointer"));
|
||||||
|
// Redraw required to apply changes immediately
|
||||||
|
text_view.queue_draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Restore defaults
|
||||||
|
g.set_uri(None);
|
||||||
|
text_view.set_cursor_from_name(Some("text"));
|
||||||
|
text_view.queue_draw();
|
||||||
|
}
|
||||||
|
}); // @TODO may be expensive for CPU, add timeout?
|
||||||
|
c
|
||||||
|
});
|
||||||
|
|
||||||
|
text_view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
use gtk::{
|
||||||
|
Align, Label, TextView, TextWindowType,
|
||||||
|
glib::{Uri, timeout_add_local_once},
|
||||||
|
pango::EllipsizeMode,
|
||||||
|
prelude::{TextViewExt, WidgetExt},
|
||||||
|
};
|
||||||
|
use std::{cell::Cell, rc::Rc, time::Duration};
|
||||||
|
|
||||||
|
pub struct Gutter {
|
||||||
|
pub label: Label,
|
||||||
|
is_active: Rc<Cell<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Gutter {
|
||||||
|
pub fn build(text_view: &TextView) -> Self {
|
||||||
|
const MARGIN_X: i32 = 8;
|
||||||
|
const MARGIN_Y: i32 = 2;
|
||||||
|
let label = Label::builder()
|
||||||
|
.css_classes(["caption", "dim-label"])
|
||||||
|
.ellipsize(EllipsizeMode::Middle)
|
||||||
|
.halign(Align::Start)
|
||||||
|
.margin_bottom(MARGIN_Y)
|
||||||
|
.margin_end(MARGIN_X)
|
||||||
|
.margin_start(MARGIN_X)
|
||||||
|
.margin_top(MARGIN_Y)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
text_view.set_gutter(TextWindowType::Bottom, Some(&label));
|
||||||
|
text_view
|
||||||
|
.gutter(TextWindowType::Bottom)
|
||||||
|
.unwrap()
|
||||||
|
.set_css_classes(&["view"]); // @TODO unspecified patch
|
||||||
|
|
||||||
|
Self {
|
||||||
|
is_active: Rc::new(Cell::new(false)),
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_uri(&self, uri: Option<&Uri>) {
|
||||||
|
match uri {
|
||||||
|
Some(uri) => {
|
||||||
|
if !self.label.is_visible() {
|
||||||
|
if !self.is_active.replace(true) {
|
||||||
|
timeout_add_local_once(Duration::from_millis(250), {
|
||||||
|
let label = self.label.clone();
|
||||||
|
let is_active = self.is_active.clone();
|
||||||
|
let uri = uri.clone();
|
||||||
|
move || {
|
||||||
|
if is_active.replace(false) {
|
||||||
|
label.set_label(&uri.to_string());
|
||||||
|
label.set_visible(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.label.set_label(&uri.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.is_active.replace(false);
|
||||||
|
self.label.set_visible(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue