From 337bf3270738505e1b8851054ee9a6127f7e5426 Mon Sep 17 00:00:00 2001 From: yggverse Date: Thu, 26 Jun 2025 18:02:52 +0300 Subject: [PATCH] implement `text/nex` renderer --- README.md | 2 +- .../window/tab/item/client/driver/nex.rs | 2 +- .../browser/window/tab/item/page/content.rs | 8 + .../window/tab/item/page/content/text.rs | 11 + .../tab/item/page/content/text/gemini.rs | 8 +- .../window/tab/item/page/content/text/nex.rs | 256 ++++++++++++++++++ .../tab/item/page/content/text/nex/gutter.rs | 68 +++++ 7 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 src/app/browser/window/tab/item/page/content/text/nex.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/nex/gutter.rs diff --git a/README.md b/README.md index d71a11d6..7a1b8c8b 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ GTK 4 / Libadwaita client written in Rust #### Text * [x] `text/gemini` * [x] `text/plain` - * [ ] `text/nex` + * [x] `text/nex` #### Images * [x] `image/gif` diff --git a/src/app/browser/window/tab/item/client/driver/nex.rs b/src/app/browser/window/tab/item/client/driver/nex.rs index cfcf82c9..a6a419e6 100644 --- a/src/app/browser/window/tab/item/client/driver/nex.rs +++ b/src/app/browser/window/tab/item/client/driver/nex.rs @@ -198,7 +198,7 @@ fn render( } else if q.ends_with(".gmi") || q.ends_with(".gemini") { p.content.to_text_gemini(&u, d) } else { - p.content.to_text_plain(d) + p.content.to_text_nex(&u, d) }; event(&p, "Parsed", Some(s)); p.search.set(Some(t.text_view)); diff --git a/src/app/browser/window/tab/item/page/content.rs b/src/app/browser/window/tab/item/page/content.rs index 54da98b1..08d5b56f 100644 --- a/src/app/browser/window/tab/item/page/content.rs +++ b/src/app/browser/window/tab/item/page/content.rs @@ -152,6 +152,14 @@ impl Content { 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( &self, file: &File, diff --git a/src/app/browser/window/tab/item/page/content/text.rs b/src/app/browser/window/tab/item/page/content/text.rs index 9a45212b..a831440f 100644 --- a/src/app/browser/window/tab/item/page/content/text.rs +++ b/src/app/browser/window/tab/item/page/content/text.rs @@ -1,4 +1,5 @@ mod gemini; +mod nex; mod plain; mod source; @@ -6,6 +7,7 @@ use super::{ItemAction, WindowAction}; use adw::ClampScrollable; use gemini::Gemini; use gtk::{ScrolledWindow, TextView, glib::Uri}; +use nex::Nex; use plain::Plain; use source::Source; use std::rc::Rc; @@ -58,6 +60,15 @@ impl Text { } } + pub fn nex(actions: (&Rc, &Rc), 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 { let source = sourceview::View::source(data); Self { diff --git a/src/app/browser/window/tab/item/page/content/text/gemini.rs b/src/app/browser/window/tab/item/page/content/text/gemini.rs index 5fc942ca..835e0a07 100644 --- a/src/app/browser/window/tab/item/page/content/text/gemini.rs +++ b/src/app/browser/window/tab/item/page/content/text/gemini.rs @@ -224,7 +224,7 @@ impl Gemini { } alt.push(match link.alt { - Some(alt) => alt.to_string(), + Some(alt) => alt, None => uri.to_string(), }); @@ -236,7 +236,7 @@ impl Gemini { .build(); if !tag.text_tag_table.add(&a) { - todo!() + panic!() } buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]); @@ -315,7 +315,7 @@ impl Gemini { if let Some(uri) = links.get(&tag) { // Select link handler by scheme return match uri.scheme().as_str() { - "gemini" | "titan" | "file" => { + "gemini" | "titan" | "nex" | "file" => { item_action.load.activate(Some(&uri.to_str()), true, false) } // Scheme not supported, delegate @@ -352,7 +352,7 @@ impl Gemini { if let Some(uri) = links.get(&tag) { // Select link handler by scheme return match uri.scheme().as_str() { - "gemini" | "titan" | "file" => { + "gemini" | "titan" | "nex" | "file" => { // Open new page in browser window_action.append.activate_stateful_once( Position::After, diff --git a/src/app/browser/window/tab/item/page/content/text/nex.rs b/src/app/browser/window/tab/item/page/content/text/nex.rs new file mode 100644 index 00000000..5064d061 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/nex.rs @@ -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, &Rc), base: &Uri, data: &str) -> Self; +} + +impl Nex for TextView { + fn nex( + (window_action, item_action): (&Rc, &Rc), + 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 = HashMap::new(); + + // Init hovered tag storage for `links` + // * maybe less expensive than update entire HashMap by iter + let hover: Rc>> = 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 + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/nex/gutter.rs b/src/app/browser/window/tab/item/page/content/text/nex/gutter.rs new file mode 100644 index 00000000..6a558ef2 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/nex/gutter.rs @@ -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>, +} + +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) + } + } + } +}