Compare commits

...

79 commits

Author SHA1 Message Date
yggverse
ac83ace83b update dependencies 2026-03-23 02:34:56 +02:00
yggverse
38f9cca422 minor syntax optimizations 2026-03-23 02:34:35 +02:00
yggverse
e92eb318b3 allow empty quote lines, update tests logic 2026-03-17 21:38:36 +02:00
yggverse
2891d73b37 update some dependencies 2026-03-16 09:09:29 +02:00
yggverse
ca9c2058ed implement italic tag 2026-03-16 09:06:31 +02:00
yggverse
2ef5e52079 update version 2026-03-16 08:35:12 +02:00
yggverse
416c0ac434 add alternative bold tags 2026-03-16 06:58:17 +02:00
yggverse
3bdabbe1b8 add missed nl 2026-03-13 23:05:30 +02:00
yggverse
3358a89735 remove extra nl separators 2026-03-13 20:50:50 +02:00
yggverse
563b228e9e strip xml tags from the markdown source 2026-03-13 20:41:51 +02:00
yggverse
b6b8f96bba add missed hr tag support, minor reference api updates 2026-03-13 17:29:42 +02:00
yggverse
86ce8ceff5 update version 2026-03-13 04:58:56 +02:00
yggverse
13e20f0df3 update regular expressions, ignore backslash skip for header / list / quote tags as inline raw 2026-03-12 01:46:45 +02:00
yggverse
84167ad745 update escapes removing logic 2026-03-12 00:47:30 +02:00
yggverse
ca29f68f69 handle escape for defined matches only 2026-03-11 20:21:37 +02:00
yggverse
905eee0aab update navigation entry on fragment change 2026-03-11 15:50:57 +02:00
yggverse
6a491751b6 fix buffer tag search by the fragment 2026-03-11 04:33:50 +02:00
yggverse
a1d9c080d1 implement context menu for the header tags (including fragment URL copy) 2026-03-11 03:31:52 +02:00
yggverse
12edd5a4f4 minor optimizations 2026-03-11 03:30:42 +02:00
yggverse
bf039dd947 show x button at left by respecting the env settings 2026-03-11 02:48:51 +02:00
yggverse
0a9b2385aa fix action targets 2026-03-11 02:40:10 +02:00
yggverse
f8afa8e085 fix alternative fragment syntax 2026-03-11 01:58:18 +02:00
yggverse
bb08b7cb9a implement copy link text, selected text, add link to the bookmarks (context menu) items; group menu items 2026-03-11 01:41:48 +02:00
yggverse
c95cb6e756 escape version 2026-03-10 21:34:13 +02:00
yggverse
9a3cb77fe7 define shared ESC const, add filter for title 2026-03-10 21:29:02 +02:00
yggverse
e4c62ca3b3 cleanup unformatted escape chars 2026-03-10 21:22:42 +02:00
yggverse
88a3e94f42 skip escaped tags 2026-03-10 20:40:52 +02:00
yggverse
c64f2d9a9b append some tags to existing tags instead of overwrite 2026-03-10 20:03:25 +02:00
yggverse
0eebd1c85d update list State api 2026-03-10 18:31:12 +02:00
yggverse
d40eab57ec fix quote expression 2026-03-10 17:52:16 +02:00
yggverse
9612c988cc try few search scenarios on result fail 2026-03-10 04:18:17 +02:00
yggverse
36568004e8 implement scroll to anchor without page load 2026-03-10 03:26:43 +02:00
yggverse
02bfc90a39 remove wakatime tracker as not in use 2026-03-10 03:15:24 +02:00
yggverse
7d8bce152b implement anchor auto-scroll behavior (on page load) 2026-03-10 02:36:51 +02:00
oooo-ps
36f5d29fa4
Merge pull request #15 from YGGverse/markdown
Markdown MIME type support
2026-03-09 22:22:33 +02:00
yggverse
12a557eb02 shut up clippy 2026-03-09 22:19:30 +02:00
yggverse
722a6c8bb8 implement listing tag 2026-03-09 22:05:34 +02:00
yggverse
666aa5caf8 update preformatted tag style 2026-03-09 20:26:13 +02:00
yggverse
fb7e00758b implement pre tag 2026-03-09 19:53:11 +02:00
yggverse
0f53a899ad rename pre to code, cleanup extra components 2026-03-09 19:20:58 +02:00
yggverse
0cc9c69438 implement code highlight and ansi features for the preformatted tag 2026-03-09 18:22:46 +02:00
yggverse
a8d25e695f draft basic multi-line code tags impl 2026-03-09 17:46:02 +02:00
yggverse
d674edc7d0 remove extras 2026-03-09 16:53:42 +02:00
yggverse
1706f14e96 remove some extra members 2026-03-09 16:47:38 +02:00
yggverse
9e787468ac add markdown support, reorder asc 2026-03-09 16:41:45 +02:00
yggverse
c6661aa656 add strike tag support 2026-03-09 06:15:38 +02:00
yggverse
5b8a469b5b add underline tag support 2026-03-09 06:09:47 +02:00
yggverse
ea2f4656a0 add bold tag support 2026-03-09 05:55:37 +02:00
yggverse
b8b85873ab allow empty alt 2026-03-09 05:28:45 +02:00
yggverse
43f348e9bb implement strip_tags filter 2026-03-09 05:20:10 +02:00
yggverse
3df4a79e0a define title based on first Header tag match 2026-03-09 04:50:35 +02:00
yggverse
8400ed2b6a make Reference tag private 2026-03-09 04:25:02 +02:00
yggverse
1af7d31d75 remove Plain tag from the renderer asset 2026-03-09 04:21:28 +02:00
yggverse
7220398492 reorganize some tags 2026-03-09 04:18:56 +02:00
yggverse
cab1610e1f add quote support 2026-03-09 03:00:58 +02:00
yggverse
c732964494 add header tags renderer 2026-03-09 02:40:42 +02:00
yggverse
e653675fa1 remove extra ns 2026-03-09 01:53:57 +02:00
yggverse
9843d49326 implement link, linked images, and images parser; temporarily disable header impl 2026-03-08 23:41:20 +02:00
yggverse
5675809320 move regex logic, add annotation tag, add some tests 2026-03-08 20:44:46 +02:00
yggverse
e61b6c400a fix default value 2026-03-08 18:33:42 +02:00
yggverse
25e505c9fb implement Reference bufferizer, draft image_link method 2026-03-08 17:45:23 +02:00
yggverse
22c50161af separate Reference impl 2026-03-08 17:25:27 +02:00
oooo-ps
71f2597bf5
Merge pull request #16 from YGGverse/fix-left-window-buttons-placement
add left window controls placement support
2026-03-08 16:25:43 +02:00
oooo-ps
df419181e6
Merge pull request #17 from YGGverse/fix-left-window-buttons-placement
add left window controls placement support
2026-03-08 16:25:32 +02:00
yggverse
81b57f92ac add left window controls placement support 2026-03-08 16:21:56 +02:00
yggverse
266b8bfa95 draft links parser 2026-03-08 06:48:24 +02:00
yggverse
c5f9690967 define expected capacity 2026-03-08 05:11:10 +02:00
yggverse
31346d1d63 implement 1-6 level header tags 2026-03-08 05:04:50 +02:00
yggverse
191057cc50 fix content detection rules 2026-03-08 03:02:00 +02:00
yggverse
fc6cce8072 init text/markdown parser (based on text/gemini) 2026-03-08 02:53:33 +02:00
yggverse
6fb7e70213 update Cargo.lock 2026-03-08 02:46:18 +02:00
yggverse
3077c3b033 update version 2026-03-08 02:24:54 +02:00
yggverse
d512e94db1 update crates api 2026-03-04 13:34:51 +02:00
yggverse
f4416c7af9 update some dependencies 2026-03-04 13:02:25 +02:00
yggverse
1d6cfb88ef implement context menu for the gemtext viewer link tags 2026-03-04 00:26:56 +02:00
yggverse
0357edccfe update versions 2026-03-03 23:44:30 +02:00
yggverse
47e686dc29 add Cargo.lock 2026-03-01 14:56:45 +02:00
yggverse
fd6b9edb35 remove custom icon (multi-platform defaults issue) 2025-12-08 09:49:46 +02:00
yggverse
8e56daa243 update version 2025-11-15 20:33:22 +02:00
39 changed files with 5144 additions and 121 deletions

1
.gitignore vendored
View file

@ -1,5 +1,4 @@
*flatpak* *flatpak*
build build
Cargo.lock
repo repo
target target

1806
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "Yoda" name = "Yoda"
version = "0.12.6" version = "0.12.10"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
readme = "README.md" readme = "README.md"
@ -22,7 +22,7 @@ features = ["gnome_46"]
[dependencies.sqlite] [dependencies.sqlite]
package = "rusqlite" package = "rusqlite"
version = "0.37.0" version = "0.38.0"
[dependencies.sourceview] [dependencies.sourceview]
package = "sourceview5" package = "sourceview5"
@ -36,11 +36,13 @@ ggemtext = "0.7.0"
indexmap = "2.10.0" indexmap = "2.10.0"
itertools = "0.14.0" itertools = "0.14.0"
libspelling = "0.4.1" libspelling = "0.4.1"
maxminddb = "0.26.0" maxminddb = "0.27.3"
openssl = "0.10.72" openssl = "0.10.72"
plurify = "0.2.0" plurify = "0.2.0"
r2d2 = "0.8.10" r2d2 = "0.8.10"
r2d2_sqlite = "0.31.0" r2d2_sqlite = "0.32.0"
regex = "1.12.3"
strip-tags = "0.1.0"
syntect = "5.2.0" syntect = "5.2.0"
# development # development

View file

@ -135,8 +135,9 @@ The Gemini protocol was designed as a minimalistic, tracking-resistant alternati
#### Text #### Text
* [x] `text/gemini` * [x] `text/gemini`
* [x] `text/plain` * [x] `text/markdown`
* [x] `text/nex` * [x] `text/nex`
* [x] `text/plain`
#### Images #### Images
* [x] `image/gif` * [x] `image/gif`
@ -165,7 +166,7 @@ The Gemini protocol was designed as a minimalistic, tracking-resistant alternati
* Glib `2.80+` * Glib `2.80+`
* Gtk `4.14+` * Gtk `4.14+`
* GtkSourceView `5.14+` * GtkSourceView `5.14+`
* libadwaita `1.5+` (Ubuntu 24.04+) * libadwaita `1.5+` (Ubuntu `24.04+`)
* libspelling `0.1+` * libspelling `0.1+`
#### Debian #### Debian
@ -234,7 +235,7 @@ flatpak-builder --force-clean build\
#### Contributors #### Contributors
![wakatime](https://wakatime.com/badge/user/0b7fe6c1-b091-4c98-b930-75cfee17c7a5/project/018ebca8-4d22-4f9e-b557-186be6553d9a.svg) ![StandWithUkraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg) ![StandWithUkraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)
### Localization ### Localization

View file

@ -27,10 +27,20 @@ impl Bar for Box {
.orientation(Orientation::Horizontal) .orientation(Orientation::Horizontal)
.spacing(8) .spacing(8)
.build(); .build();
// left controls placement
if gtk::Settings::default().is_some_and(|s| {
s.gtk_decoration_layout()
.is_some_and(|l| l.starts_with("close"))
}) {
g_box.append(&Control::left().window_controls);
g_box.append(&MenuButton::menu((browser_action, window_action)));
g_box.append(&TabBar::tab(window_action, view))
// default layout
} else {
g_box.append(&TabBar::tab(window_action, view)); g_box.append(&TabBar::tab(window_action, view));
g_box.append(&MenuButton::menu((browser_action, window_action))); g_box.append(&MenuButton::menu((browser_action, window_action)));
g_box.append(&Control::new().window_controls); g_box.append(&Control::right().window_controls)
}
g_box g_box
} }
} }

View file

@ -8,13 +8,12 @@ pub struct Control {
impl Default for Control { impl Default for Control {
fn default() -> Self { fn default() -> Self {
Self::new() Self::right()
} }
} }
impl Control { impl Control {
// Construct pub fn right() -> Self {
pub fn new() -> Self {
Self { Self {
window_controls: WindowControls::builder() window_controls: WindowControls::builder()
.margin_end(MARGIN) .margin_end(MARGIN)
@ -22,4 +21,12 @@ impl Control {
.build(), .build(),
} }
} }
pub fn left() -> Self {
Self {
window_controls: WindowControls::builder()
.margin_end(MARGIN)
.side(PackType::Start)
.build(),
}
}
} }

View file

@ -14,8 +14,12 @@ impl Tab for TabBar {
fn tab(window_action: &Rc<WindowAction>, view: &TabView) -> Self { fn tab(window_action: &Rc<WindowAction>, view: &TabView) -> Self {
TabBar::builder() TabBar::builder()
.autohide(false) .autohide(false)
.expand_tabs(false)
.end_action_widget(&Button::append(window_action)) // @TODO find solution to append after tabs .end_action_widget(&Button::append(window_action)) // @TODO find solution to append after tabs
.expand_tabs(false)
.inverted(gtk::Settings::default().is_some_and(|s| {
s.gtk_decoration_layout()
.is_some_and(|l| l.starts_with("close"))
})) // show `x` button at left by respecting the env settings
.view(view) .view(view)
.build() .build()
} }

View file

@ -10,7 +10,6 @@ use adw::{TabPage, TabView};
use anyhow::Result; use anyhow::Result;
use gtk::{ use gtk::{
Box, Orientation, Box, Orientation,
gio::Icon,
glib::Propagation, glib::Propagation,
prelude::{ActionExt, EditableExt, EntryExt, WidgetExt}, prelude::{ActionExt, EditableExt, EntryExt, WidgetExt},
}; };
@ -44,13 +43,6 @@ impl Tab {
.menu_model(&gtk::gio::Menu::menu(window_action)) .menu_model(&gtk::gio::Menu::menu(window_action))
.build(); .build();
// Change default icon (if available in the system icon set)
// * visible for pinned tabs only
// * @TODO not default GTK behavior, make this feature optional
if let Ok(default_icon) = Icon::for_string("view-pin-symbolic") {
tab_view.set_default_icon(&default_icon);
}
// Init events // Init events
tab_view.connect_setup_menu({ tab_view.connect_setup_menu({
let index = index.clone(); let index = index.clone();

View file

@ -71,6 +71,31 @@ impl File {
.set_mime(Some(content_type.to_string())); .set_mime(Some(content_type.to_string()));
} }
match content_type.as_str() { match content_type.as_str() {
"text/gemini" => {
if matches!(*feature, Feature::Source) {
load_contents_async(file, cancellable, move |result| {
match result {
Ok(data) => {
Text::Source(uri, data).handle(&page)
}
Err(message) => {
Status::Failure(message).handle(&page)
}
}
})
} else {
load_contents_async(file, cancellable, move |result| {
match result {
Ok(data) => {
Text::Gemini(uri, data).handle(&page)
}
Err(message) => {
Status::Failure(message).handle(&page)
}
}
})
}
}
"text/plain" => { "text/plain" => {
if matches!(*feature, Feature::Source) { if matches!(*feature, Feature::Source) {
load_contents_async(file, cancellable, move |result| { load_contents_async(file, cancellable, move |result| {
@ -94,6 +119,18 @@ impl File {
} }
} }
}); });
} else if url.ends_with(".md") || url.ends_with(".markdown")
{
load_contents_async(file, cancellable, move |result| {
match result {
Ok(data) => {
Text::Markdown(uri, data).handle(&page)
}
Err(message) => {
Status::Failure(message).handle(&page)
}
}
})
} else { } else {
load_contents_async(file, cancellable, move |result| { load_contents_async(file, cancellable, move |result| {
match result { match result {
@ -107,6 +144,31 @@ impl File {
}) })
} }
} }
"text/markdown" => {
if matches!(*feature, Feature::Source) {
load_contents_async(file, cancellable, move |result| {
match result {
Ok(data) => {
Text::Source(uri, data).handle(&page)
}
Err(message) => {
Status::Failure(message).handle(&page)
}
}
})
} else {
load_contents_async(file, cancellable, move |result| {
match result {
Ok(data) => {
Text::Markdown(uri, data).handle(&page)
}
Err(message) => {
Status::Failure(message).handle(&page)
}
}
})
}
}
"image/png" | "image/gif" | "image/jpeg" | "image/webp" => { "image/png" | "image/gif" | "image/jpeg" | "image/webp" => {
match gtk::gdk::Texture::from_file(&file) { match gtk::gdk::Texture::from_file(&file) {
Ok(texture) => { Ok(texture) => {

View file

@ -2,12 +2,13 @@ use gtk::glib::Uri;
pub enum Text { pub enum Text {
Gemini(Uri, String), Gemini(Uri, String),
Markdown(Uri, String),
Plain(Uri, String), Plain(Uri, String),
Source(Uri, String), Source(Uri, String),
} }
impl Text { impl Text {
pub fn handle(&self, page: &super::Page) { pub fn handle(&self, page: &std::rc::Rc<super::Page>) {
page.navigation page.navigation
.request .request
.info .info
@ -20,7 +21,15 @@ impl Text {
.info .info
.borrow_mut() .borrow_mut()
.set_mime(Some("text/gemini".to_string())); .set_mime(Some("text/gemini".to_string()));
page.content.to_text_gemini(uri, data) page.content.to_text_gemini(&page.profile, uri, data)
}),
Self::Markdown(uri, data) => (uri, {
page.navigation
.request
.info
.borrow_mut()
.set_mime(Some("text/markdown".to_string()));
page.content.to_text_markdown(page, uri, data)
}), }),
Self::Plain(uri, data) => (uri, page.content.to_text_plain(data)), Self::Plain(uri, data) => (uri, page.content.to_text_plain(data)),
Self::Source(uri, data) => (uri, page.content.to_text_source(data)), Self::Source(uri, data) => (uri, page.content.to_text_source(data)),

View file

@ -357,7 +357,8 @@ fn handle(
page.content.to_text_source(data) page.content.to_text_source(data)
} else { } else {
match m.as_str() { match m.as_str() {
"text/gemini" => page.content.to_text_gemini(&uri, data), "text/gemini" => page.content.to_text_gemini(&page.profile, &uri, data),
"text/markdown" => page.content.to_text_markdown(&page, &uri, data),
"text/plain" => page.content.to_text_plain(data), "text/plain" => page.content.to_text_plain(data),
_ => panic!() // unexpected _ => panic!() // unexpected
} }

View file

@ -299,7 +299,7 @@ fn render(
} else if q.ends_with("/") { } else if q.ends_with("/") {
p.content.to_text_nex(&u, d) p.content.to_text_nex(&u, d)
} 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(&p.profile, &u, d)
} else { } else {
p.content.to_text_plain(d) p.content.to_text_plain(d)
}; };

View file

@ -7,6 +7,8 @@ use directory::Directory;
use image::Image; use image::Image;
use text::Text; use text::Text;
use crate::{app::browser::window::tab::item::page::Page, profile::Profile};
use super::{ItemAction, TabAction, WindowAction}; use super::{ItemAction, TabAction, WindowAction};
use adw::StatusPage; use adw::StatusPage;
use gtk::{ use gtk::{
@ -126,9 +128,14 @@ impl Content {
} }
/// `text/gemini` /// `text/gemini`
pub fn to_text_gemini(&self, base: &Uri, data: &str) -> Text { pub fn to_text_gemini(&self, profile: &Rc<Profile>, base: &Uri, data: &str) -> Text {
self.clean(); self.clean();
match Text::gemini((&self.window_action, &self.item_action), base, data) { match Text::gemini(
(&self.window_action, &self.item_action),
profile,
base,
data,
) {
Ok(text) => { Ok(text) => {
self.g_box.append(&text.scrolled_window); self.g_box.append(&text.scrolled_window);
text text
@ -154,6 +161,14 @@ impl Content {
} }
} }
/// `text/markdown`
pub fn to_text_markdown(&self, page: &Rc<Page>, base: &Uri, data: &str) -> Text {
self.clean();
let m = Text::markdown((&self.window_action, &self.item_action), page, base, data);
self.g_box.append(&m.scrolled_window);
m
}
/// `text/plain` /// `text/plain`
pub fn to_text_plain(&self, data: &str) -> Text { pub fn to_text_plain(&self, data: &str) -> Text {
self.clean(); self.clean();

View file

@ -15,6 +15,8 @@ impl Format for FileInfo {
if content_type == "text/plain" { if content_type == "text/plain" {
if display_name.ends_with(".gmi") || display_name.ends_with(".gemini") { if display_name.ends_with(".gmi") || display_name.ends_with(".gemini") {
"text/gemini".into() "text/gemini".into()
} else if display_name.ends_with(".md") || display_name.ends_with(".markdown") {
"text/markdown".into()
} else { } else {
content_type content_type
} }

View file

@ -1,12 +1,16 @@
mod gemini; mod gemini;
mod markdown;
mod nex; mod nex;
mod plain; mod plain;
mod source; mod source;
use crate::{app::browser::window::tab::item::page::Page, profile::Profile};
use super::{ItemAction, WindowAction}; 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 markdown::Markdown;
use nex::Nex; use nex::Nex;
use plain::Plain; use plain::Plain;
use source::Source; use source::Source;
@ -25,10 +29,11 @@ pub struct Text {
impl Text { impl Text {
pub fn gemini( pub fn gemini(
actions: (&Rc<WindowAction>, &Rc<ItemAction>), actions: (&Rc<WindowAction>, &Rc<ItemAction>),
profile: &Rc<Profile>,
base: &Uri, base: &Uri,
gemtext: &str, gemtext: &str,
) -> Result<Self, (String, Option<Self>)> { ) -> Result<Self, (String, Option<Self>)> {
match Gemini::build(actions, base, gemtext) { match Gemini::build(actions, profile, base, gemtext) {
Ok(widget) => Ok(Self { Ok(widget) => Ok(Self {
scrolled_window: reader(&widget.text_view), scrolled_window: reader(&widget.text_view),
text_view: widget.text_view, text_view: widget.text_view,
@ -51,6 +56,22 @@ impl Text {
} }
} }
pub fn markdown(
actions: (&Rc<WindowAction>, &Rc<ItemAction>),
page: &Rc<Page>,
base: &Uri,
gemtext: &str,
) -> Self {
let markdown = Markdown::build(actions, page, base, gemtext);
Self {
scrolled_window: reader(&markdown.text_view),
text_view: markdown.text_view,
meta: Meta {
title: markdown.title,
},
}
}
pub fn plain(data: &str) -> Self { pub fn plain(data: &str) -> Self {
let text_view = TextView::plain(data); let text_view = TextView::plain(data);
Self { Self {

View file

@ -5,23 +5,23 @@ mod icon;
mod syntax; mod syntax;
mod tag; mod tag;
pub use error::Error;
use gutter::Gutter;
use icon::Icon;
use syntax::Syntax;
use tag::Tag;
use super::{ItemAction, WindowAction}; use super::{ItemAction, WindowAction};
use crate::app::browser::window::action::Position; use crate::{app::browser::window::action::Position, profile::Profile};
pub use error::Error;
use gtk::{ use gtk::{
EventControllerMotion, GestureClick, TextBuffer, TextTag, TextView, TextWindowType, EventControllerMotion, GestureClick, TextBuffer, TextTag, TextView, TextWindowType,
UriLauncher, Window, WrapMode, UriLauncher, Window, WrapMode,
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, RGBA}, gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA},
gio::Cancellable, gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup},
glib::Uri, glib::{Uri, uuid_string_random},
prelude::{TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt}, prelude::{PopoverExt, TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt},
}; };
use gutter::Gutter;
use icon::Icon;
use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant};
use std::{cell::Cell, collections::HashMap, rc::Rc}; use std::{cell::Cell, collections::HashMap, rc::Rc};
use syntax::Syntax;
use tag::Tag;
pub const NEW_LINE: &str = "\n"; pub const NEW_LINE: &str = "\n";
@ -36,6 +36,7 @@ impl Gemini {
/// Build new `Self` /// Build new `Self`
pub fn build( pub fn build(
(window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>), (window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>),
profile: &Rc<Profile>,
base: &Uri, base: &Uri,
gemtext: &str, gemtext: &str,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
@ -150,11 +151,7 @@ impl Gemini {
match syntax.highlight(&c.value, alt) { match syntax.highlight(&c.value, alt) {
Ok(highlight) => { Ok(highlight) => {
for (syntax_tag, entity) in highlight { for (syntax_tag, entity) in highlight {
// Register new tag assert!(tag.text_tag_table.add(&syntax_tag));
if !tag.text_tag_table.add(&syntax_tag) {
todo!()
}
// Append tag to buffer
buffer.insert_with_tags( buffer.insert_with_tags(
&mut buffer.end_iter(), &mut buffer.end_iter(),
&entity, &entity,
@ -165,11 +162,7 @@ impl Gemini {
Err(_) => { Err(_) => {
// Try ANSI/SGR format (terminal emulation) @TODO optional // Try ANSI/SGR format (terminal emulation) @TODO optional
for (syntax_tag, entity) in ansi::format(&c.value) { for (syntax_tag, entity) in ansi::format(&c.value) {
// Register new tag assert!(tag.text_tag_table.add(&syntax_tag));
if !tag.text_tag_table.add(&syntax_tag) {
todo!()
}
// Append tag to buffer
buffer.insert_with_tags( buffer.insert_with_tags(
&mut buffer.end_iter(), &mut buffer.end_iter(),
&entity, &entity,
@ -186,7 +179,7 @@ impl Gemini {
// Skip other actions for this line // Skip other actions for this line
continue; continue;
} }
Err(_) => todo!(), Err(_) => panic!(),
} }
} }
} }
@ -217,10 +210,10 @@ impl Gemini {
// Is link // Is link
if let Some(link) = ggemtext::line::Link::parse(line) { if let Some(link) = ggemtext::line::Link::parse(line) {
if let Some(uri) = link.uri(Some(base)) { if let Some(uri) = link.uri(Some(base)) {
let mut alt = Vec::new(); let mut alt = Vec::with_capacity(2);
if uri.scheme() != base.scheme() { if uri.scheme() != base.scheme() {
alt.push("".to_string()); alt.push(LINK_EXTERNAL_INDICATOR.to_string());
} }
alt.push(match link.alt { alt.push(match link.alt {
@ -235,9 +228,7 @@ impl Gemini {
.wrap_mode(WrapMode::Word) .wrap_mode(WrapMode::Word)
.build(); .build();
if !tag.text_tag_table.add(&a) { assert!(tag.text_tag_table.add(&a));
panic!()
}
buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]); buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]);
buffer.insert(&mut buffer.end_iter(), NEW_LINE); buffer.insert(&mut buffer.end_iter(), NEW_LINE);
@ -284,14 +275,170 @@ impl Gemini {
buffer.insert(&mut buffer.end_iter(), NEW_LINE); buffer.insert(&mut buffer.end_iter(), NEW_LINE);
} }
// Context menu
let action_link_tab =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_tab.connect_activate({
let window_action = window_action.clone();
move |this, _| {
open_link_in_new_tab(
&this.state().unwrap().get::<String>().unwrap(),
&window_action,
)
}
});
let action_link_copy_url =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy_url.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_copy_text =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy_text.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_copy_text_selected =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy_text_selected.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_bookmark =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_bookmark.connect_activate({
let p = profile.clone();
move |this, _| {
let state = this.state().unwrap().get::<String>().unwrap();
p.bookmark.toggle(&state, None).unwrap();
}
});
let action_link_download =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_download.connect_activate({
let window_action = window_action.clone();
move |this, _| {
open_link_in_new_tab(
&link_prefix(
this.state().unwrap().get::<String>().unwrap(),
LINK_PREFIX_DOWNLOAD,
),
&window_action,
)
}
});
let action_link_source =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_source.connect_activate({
let window_action = window_action.clone();
move |this, _| {
open_link_in_new_tab(
&link_prefix(
this.state().unwrap().get::<String>().unwrap(),
LINK_PREFIX_SOURCE,
),
&window_action,
)
}
});
let link_context_group_id = uuid_string_random();
text_view.insert_action_group(
&link_context_group_id,
Some(&{
let g = SimpleActionGroup::new();
g.add_action(&action_link_tab);
g.add_action(&action_link_copy_url);
g.add_action(&action_link_copy_text);
g.add_action(&action_link_copy_text_selected);
g.add_action(&action_link_bookmark);
g.add_action(&action_link_download);
g.add_action(&action_link_source);
g
}),
);
let link_context = gtk::PopoverMenu::from_model(Some(&{
let m = Menu::new();
m.append(
Some("Open Link in New Tab"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_tab.name()
)),
);
m.append_section(None, &{
let m_copy = Menu::new();
m_copy.append(
Some("Copy Link URL"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_url.name()
)),
);
m_copy.append(
Some("Copy Link Text"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_text.name()
)),
);
m_copy.append(
Some("Copy Text Selected"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_text_selected.name()
)),
);
m_copy
});
m.append_section(None, &{
let m_other = Menu::new();
m_other.append(
Some("Bookmark Link"), // @TODO highlight state
Some(&format!(
"{link_context_group_id}.{}",
action_link_bookmark.name()
)),
);
m_other.append(
Some("Download Link"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_download.name()
)),
);
m_other.append(
Some("View Link as Source"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_source.name()
)),
);
m_other
});
m
}));
link_context.set_parent(&text_view);
// Init additional controllers // Init additional controllers
let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build();
let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build(); let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build();
let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build();
let secondary_button_controller = GestureClick::builder()
.button(BUTTON_SECONDARY)
.propagation_phase(gtk::PropagationPhase::Capture)
.build();
let motion_controller = EventControllerMotion::new(); let motion_controller = EventControllerMotion::new();
text_view.add_controller(primary_button_controller.clone());
text_view.add_controller(middle_button_controller.clone()); text_view.add_controller(middle_button_controller.clone());
text_view.add_controller(motion_controller.clone()); text_view.add_controller(motion_controller.clone());
text_view.add_controller(primary_button_controller.clone());
text_view.add_controller(secondary_button_controller.clone());
// Init shared reference container for HashTable collected // Init shared reference container for HashTable collected
let links = Rc::new(links); let links = Rc::new(links);
@ -308,27 +455,92 @@ impl Gemini {
window_x as i32, window_x as i32,
window_y as i32, window_y as i32,
); );
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) { if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
for tag in iter.tags() { for tag in iter.tags() {
// Tag is link // Tag is link
if let Some(uri) = links.get(&tag) { if let Some(uri) = links.get(&tag) {
// Select link handler by scheme return open_link_in_current_tab(&uri.to_string(), &item_action);
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,
|result| {
if let Err(e) = result {
println!("{e}")
} }
}
}
});
secondary_button_controller.connect_pressed({
let links = links.clone();
let text_view = text_view.clone();
let link_context = link_context.clone();
move |_, _, window_x, window_y| {
let x = window_x as i32;
let y = window_y as i32;
// Detect tag match current coords hovered
let (buffer_x, buffer_y) =
text_view.window_to_buffer_coords(TextWindowType::Widget, x, y);
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) {
let request_str = uri.to_str();
let request_var = request_str.to_variant();
let is_prefix_link = is_prefix_link(&request_str);
// Open in the new tab
action_link_tab.set_state(&request_var);
action_link_tab.set_enabled(!request_str.is_empty());
// Copy link to the clipboard
action_link_copy_url.set_state(&request_var);
action_link_copy_url.set_enabled(!request_str.is_empty());
// Copy link text
{
let mut start_iter = iter;
let mut end_iter = iter;
if !start_iter.starts_tag(Some(&tag)) {
start_iter.backward_to_tag_toggle(Some(&tag));
}
if !end_iter.ends_tag(Some(&tag)) {
end_iter.forward_to_tag_toggle(Some(&tag));
}
let tagged_text = text_view
.buffer()
.text(&start_iter, &end_iter, false)
.replace(LINK_EXTERNAL_INDICATOR, "")
.trim()
.to_string();
action_link_copy_text.set_state(&tagged_text.to_variant());
action_link_copy_text.set_enabled(!tagged_text.is_empty());
}
// Copy link text (if) selected
action_link_copy_text_selected.set_enabled(
if let Some((start, end)) = buffer.selection_bounds() {
let selected = buffer.text(&start, &end, false);
action_link_copy_text_selected
.set_state(&selected.to_variant());
!selected.is_empty()
} else {
false
}, },
), );
}; // @TODO common handler?
// Bookmark
action_link_bookmark.set_state(&request_var);
action_link_bookmark.set_enabled(is_prefix_link);
// Download (new tab)
action_link_download.set_state(&request_var);
action_link_download.set_enabled(is_prefix_link);
// View as Source (new tab)
action_link_source.set_state(&request_var);
action_link_source.set_enabled(is_prefix_link);
// Toggle
link_context
.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x, y, 1, 1)));
link_context.popup()
} }
} }
} }
@ -350,30 +562,7 @@ impl Gemini {
for tag in iter.tags() { for tag in iter.tags() {
// Tag is link // Tag is link
if let Some(uri) = links.get(&tag) { if let Some(uri) = links.get(&tag) {
// Select link handler by scheme return open_link_in_new_tab(&uri.to_string(), &window_action);
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,
|result| {
if let Err(e) = result {
println!("{e}")
}
},
),
}; // @TODO common handler?
} }
} }
} }
@ -432,3 +621,59 @@ impl Gemini {
} }
} }
} }
fn is_internal_link(request: &str) -> bool {
// schemes
request.starts_with("gemini://")
|| request.starts_with("titan://")
|| request.starts_with("nex://")
|| request.starts_with("file://")
// prefix
|| request.starts_with("download:")
|| request.starts_with("source:")
}
fn is_prefix_link(request: &str) -> bool {
request.starts_with("gemini://")
|| request.starts_with("nex://")
|| request.starts_with("file://")
}
fn open_link_in_external_app(request: &str) {
UriLauncher::new(request).launch(Window::NONE, Cancellable::NONE, |r| {
if let Err(e) = r {
println!("{e}") // @TODO use warn macro
}
})
}
fn open_link_in_current_tab(request: &str, item_action: &ItemAction) {
if is_internal_link(request) {
item_action.load.activate(Some(request), true, false)
} else {
open_link_in_external_app(request)
}
}
fn open_link_in_new_tab(request: &str, window_action: &WindowAction) {
if is_internal_link(request) {
window_action.append.activate_stateful_once(
Position::After,
Some(request.into()),
false,
false,
true,
true,
);
} else {
open_link_in_external_app(request)
}
}
fn link_prefix(request: String, prefix: &str) -> String {
format!("{prefix}{}", request.trim_start_matches(prefix))
}
const LINK_EXTERNAL_INDICATOR: &str = "";
const LINK_PREFIX_DOWNLOAD: &str = "download:";
const LINK_PREFIX_SOURCE: &str = "source:";

View file

@ -0,0 +1,610 @@
mod gutter;
mod tags;
use super::{ItemAction, WindowAction};
use crate::app::browser::window::{action::Position, tab::item::page::Page};
use gtk::{
EventControllerMotion, GestureClick, PopoverMenu, TextBuffer, TextTag, TextTagTable, TextView,
TextWindowType, UriLauncher, Window, WrapMode,
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA},
gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup},
glib::{ControlFlow, GString, Uri, idle_add_local, uuid_string_random},
prelude::{EditableExt, PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt},
};
use gutter::Gutter;
use regex::Regex;
use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant};
use std::{cell::Cell, collections::HashMap, rc::Rc};
use strip_tags::*;
use tags::Tags;
pub struct Markdown {
pub title: Option<String>,
pub text_view: TextView,
}
impl Markdown {
// Constructors
/// Build new `Self`
pub fn build(
(window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>),
page: &Rc<Page>,
base: &Uri,
markdown: &str,
) -> Self {
// Init HashMap storage (for event controllers)
let mut links: HashMap<TextTag, Uri> = HashMap::new();
let mut headers: HashMap<TextTag, (String, 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 tags
let mut tags = Tags::new();
// Init new text buffer
let buffer = TextBuffer::new(Some(&TextTagTable::new()));
buffer.set_text(
Regex::new(r"\n{3,}")
.unwrap()
.replace_all(&strip_tags(markdown), "\n\n")
.trim(),
); // @TODO extract `<img>` tags?
// 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)
.right_margin(MARGIN)
.top_margin(MARGIN)
.vexpand(true)
.wrap_mode(WrapMode::Word)
.build()
};
// Init gutter widget (the tooltip on URL tags hover)
let gutter = Gutter::build(&text_view);
// Render markdown tags
let title = tags.render(&text_view, base, &link_color.0, &mut links, &mut headers);
// Headers context menu (fragment capture)
let action_header_copy_url =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_header_copy_url.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_header_copy_text =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_header_copy_text.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_header_copy_text_selected =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_header_copy_text_selected.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let header_context_group_id = uuid_string_random();
text_view.insert_action_group(
&header_context_group_id,
Some(&{
let g = SimpleActionGroup::new();
g.add_action(&action_header_copy_url);
g.add_action(&action_header_copy_text);
g.add_action(&action_header_copy_text_selected);
g
}),
);
let header_context = PopoverMenu::from_model(Some(&{
let m = Menu::new();
m.append(
Some("Copy Header Link"),
Some(&format!(
"{header_context_group_id}.{}",
action_header_copy_url.name()
)),
);
m.append(
Some("Copy Header Text"),
Some(&format!(
"{header_context_group_id}.{}",
action_header_copy_text.name()
)),
);
m.append(
Some("Copy Text Selected"),
Some(&format!(
"{header_context_group_id}.{}",
action_header_copy_text_selected.name()
)),
);
m
}));
header_context.set_parent(&text_view);
// Link context menu
let action_link_tab =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_tab.connect_activate({
let window_action = window_action.clone();
move |this, _| {
open_link_in_new_tab(
&this.state().unwrap().get::<String>().unwrap(),
&window_action,
)
}
});
let action_link_copy_url =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy_url.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_copy_text =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy_text.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_copy_text_selected =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy_text_selected.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_bookmark =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_bookmark.connect_activate({
let p = page.profile.clone();
move |this, _| {
let state = this.state().unwrap().get::<String>().unwrap();
p.bookmark.toggle(&state, None).unwrap();
}
});
let action_link_download =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_download.connect_activate({
let window_action = window_action.clone();
move |this, _| {
open_link_in_new_tab(
&link_prefix(
this.state().unwrap().get::<String>().unwrap(),
LINK_PREFIX_DOWNLOAD,
),
&window_action,
)
}
});
let action_link_source =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_source.connect_activate({
let window_action = window_action.clone();
move |this, _| {
open_link_in_new_tab(
&link_prefix(
this.state().unwrap().get::<String>().unwrap(),
LINK_PREFIX_SOURCE,
),
&window_action,
)
}
});
let link_context_group_id = uuid_string_random();
text_view.insert_action_group(
&link_context_group_id,
Some(&{
let g = SimpleActionGroup::new();
g.add_action(&action_link_tab);
g.add_action(&action_link_copy_url);
g.add_action(&action_link_copy_text);
g.add_action(&action_link_copy_text_selected);
g.add_action(&action_link_bookmark);
g.add_action(&action_link_download);
g.add_action(&action_link_source);
g
}),
);
let link_context = PopoverMenu::from_model(Some(&{
let m = Menu::new();
m.append(
Some("Open Link in New Tab"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_tab.name()
)),
);
m.append_section(None, &{
let m_copy = Menu::new();
m_copy.append(
Some("Copy Link URL"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_url.name()
)),
);
m_copy.append(
Some("Copy Link Text"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_text.name()
)),
);
m_copy.append(
Some("Copy Text Selected"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_text_selected.name()
)),
);
m_copy
});
m.append_section(None, &{
let m_other = Menu::new();
m_other.append(
Some("Bookmark Link"), // @TODO highlight state
Some(&format!(
"{link_context_group_id}.{}",
action_link_bookmark.name()
)),
);
m_other.append(
Some("Download Link"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_download.name()
)),
);
m_other.append(
Some("View Link as Source"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_source.name()
)),
);
m_other
});
m
}));
link_context.set_parent(&text_view);
// Init additional controllers
let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build();
let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build();
let secondary_button_controller = GestureClick::builder()
.button(BUTTON_SECONDARY)
.propagation_phase(gtk::PropagationPhase::Capture)
.build();
let motion_controller = EventControllerMotion::new();
text_view.add_controller(middle_button_controller.clone());
text_view.add_controller(motion_controller.clone());
text_view.add_controller(primary_button_controller.clone());
text_view.add_controller(secondary_button_controller.clone());
// Init shared reference container for HashTable collected
let links = Rc::new(links);
let headers = Rc::new(headers);
// Init events
primary_button_controller.connect_released({
let headers = headers.clone();
let item_action = item_action.clone();
let links = links.clone();
let page = page.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) {
return if let Some(fragment) = uri.fragment() {
scroll_to_anchor(&page, &text_view, &headers, fragment);
} else {
open_link_in_current_tab(&uri.to_string(), &item_action);
};
}
}
}
}
});
secondary_button_controller.connect_pressed({
let headers = headers.clone();
let link_context = link_context.clone();
let links = links.clone();
let text_view = text_view.clone();
move |_, _, window_x, window_y| {
let x = window_x as i32;
let y = window_y as i32;
// Detect tag match current coords hovered
let (buffer_x, buffer_y) =
text_view.window_to_buffer_coords(TextWindowType::Widget, x, y);
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) {
let request_str = uri.to_str();
let request_var = request_str.to_variant();
let is_prefix_link = is_prefix_link(&request_str);
// Open in the new tab
action_link_tab.set_state(&request_var);
action_link_tab.set_enabled(!request_str.is_empty());
// Copy link to the clipboard
action_link_copy_url.set_state(&request_var);
action_link_copy_url.set_enabled(!request_str.is_empty());
// Copy link text
{
let mut start_iter = iter;
let mut end_iter = iter;
if !start_iter.starts_tag(Some(&tag)) {
start_iter.backward_to_tag_toggle(Some(&tag));
}
if !end_iter.ends_tag(Some(&tag)) {
end_iter.forward_to_tag_toggle(Some(&tag));
}
let tagged_text = text_view
.buffer()
.text(&start_iter, &end_iter, false)
.replace(LINK_EXTERNAL_INDICATOR, "")
.trim()
.to_string();
action_link_copy_text.set_state(&tagged_text.to_variant());
action_link_copy_text.set_enabled(!tagged_text.is_empty());
}
// Copy link text (if) selected
action_link_copy_text_selected.set_enabled(
if let Some((start, end)) = buffer.selection_bounds() {
let selected = buffer.text(&start, &end, false);
action_link_copy_text_selected
.set_state(&selected.to_variant());
!selected.is_empty()
} else {
false
},
);
// Bookmark
action_link_bookmark.set_state(&request_var);
action_link_bookmark.set_enabled(is_prefix_link);
// Download (new tab)
action_link_download.set_state(&request_var);
action_link_download.set_enabled(is_prefix_link);
// View as Source (new tab)
action_link_source.set_state(&request_var);
action_link_source.set_enabled(is_prefix_link);
// Toggle
link_context
.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x, y, 1, 1)));
link_context.popup()
}
// Tag is header
if let Some((title, uri)) = headers.get(&tag) {
let request_str = uri.to_str();
let request_var = request_str.to_variant();
// Copy link to the clipboard
action_header_copy_url.set_state(&request_var);
action_header_copy_url.set_enabled(!request_str.is_empty());
// Copy header text
action_header_copy_text.set_state(&title.to_variant());
action_header_copy_text.set_enabled(!title.is_empty());
// Copy header text (if) selected
action_header_copy_text_selected.set_enabled(
if let Some((start, end)) = buffer.selection_bounds() {
let selected = buffer.text(&start, &end, false);
action_header_copy_text_selected
.set_state(&selected.to_variant());
!selected.is_empty()
} else {
false
},
);
// Toggle
header_context
.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x, y, 1, 1)));
header_context.popup()
}
}
}
}
});
middle_button_controller.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) {
return open_link_in_new_tab(&uri.to_string(), &window_action);
}
}
}
}
}); // for a note: this action sensitive to focus out
motion_controller.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
gutter.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
gutter.set_uri(None);
text_view.set_cursor_from_name(Some("text"));
text_view.queue_draw();
}
}); // @TODO may be expensive for CPU, add timeout?
// Anchor auto-scroll behavior
idle_add_local({
let base = base.clone();
let page = page.clone();
let text_view = text_view.clone();
move || {
if let Some(fragment) = base.fragment() {
scroll_to_anchor(&page, &text_view, &headers, fragment);
}
ControlFlow::Break
}
});
Self { text_view, title }
}
}
fn scroll_to_anchor(
page: &Rc<Page>,
text_view: &TextView,
headers: &HashMap<TextTag, (String, Uri)>,
fragment: GString,
) {
if let Some((tag, (_, uri))) = headers.iter().find(|(_, (_, uri))| {
uri.fragment()
.is_some_and(|f| fragment == tags::format_header_fragment(&f))
}) {
let mut iter = text_view.buffer().start_iter();
if iter.starts_tag(Some(tag)) || iter.forward_to_tag_toggle(Some(tag)) {
text_view.scroll_to_iter(&mut iter, 0.0, true, 0.0, 0.0);
}
page.navigation.request.entry.set_text(&uri.to_string())
}
}
fn is_internal_link(request: &str) -> bool {
// schemes
request.starts_with("gemini://")
|| request.starts_with("titan://")
|| request.starts_with("nex://")
|| request.starts_with("file://")
// prefix
|| request.starts_with("download:")
|| request.starts_with("source:")
}
fn is_prefix_link(request: &str) -> bool {
request.starts_with("gemini://")
|| request.starts_with("nex://")
|| request.starts_with("file://")
}
fn open_link_in_external_app(request: &str) {
UriLauncher::new(request).launch(Window::NONE, Cancellable::NONE, |r| {
if let Err(e) = r {
println!("{e}") // @TODO use warn macro
}
})
}
fn open_link_in_current_tab(request: &str, item_action: &ItemAction) {
if is_internal_link(request) {
item_action.load.activate(Some(request), true, false)
} else {
open_link_in_external_app(request)
}
}
fn open_link_in_new_tab(request: &str, window_action: &WindowAction) {
if is_internal_link(request) {
window_action.append.activate_stateful_once(
Position::After,
Some(request.into()),
false,
false,
true,
true,
);
} else {
open_link_in_external_app(request)
}
}
fn link_prefix(request: String, prefix: &str) -> String {
format!("{prefix}{}", request.trim_start_matches(prefix))
}
const LINK_EXTERNAL_INDICATOR: &str = "";
const LINK_PREFIX_DOWNLOAD: &str = "download:";
const LINK_PREFIX_SOURCE: &str = "source:";

View file

@ -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)
}
}
}
}

View file

@ -0,0 +1,5 @@
pub mod code;
pub mod header;
pub mod link;
pub mod list;
pub mod quote;

View file

@ -0,0 +1,139 @@
mod bold;
mod code;
mod header;
mod hr;
mod italic;
mod list;
mod pre;
mod quote;
mod reference;
mod strike;
mod underline;
use bold::Bold;
use code::Code;
use gtk::{
TextSearchFlags, TextTag, TextView,
gdk::RGBA,
glib::{GString, Uri},
prelude::{TextBufferExt, TextViewExt},
};
use header::Header;
use italic::Italic;
use pre::Pre;
use quote::Quote;
use std::collections::HashMap;
use strike::Strike;
use underline::Underline;
pub struct Tags {
pub bold: Bold,
pub code: Code,
pub header: Header,
pub italic: Italic,
pub pre: Pre,
pub quote: Quote,
pub strike: Strike,
pub underline: Underline,
}
impl Default for Tags {
fn default() -> Self {
Self::new()
}
}
impl Tags {
// Construct
pub fn new() -> Self {
Self {
bold: Bold::new(),
code: Code::new(),
header: Header::new(),
italic: Italic::new(),
pre: Pre::new(),
quote: Quote::new(),
strike: Strike::new(),
underline: Underline::new(),
}
}
pub fn render(
&mut self,
text_view: &TextView,
base: &Uri,
link_color: &RGBA,
links: &mut HashMap<TextTag, Uri>,
headers: &mut HashMap<TextTag, (String, Uri)>,
) -> Option<String> {
let buffer = text_view.buffer();
// Collect all code blocks first,
// and temporarily replace them with placeholder ID
self.code.collect(&buffer);
// Keep in order!
let title = self.header.render(&buffer, base, headers);
list::render(&buffer);
self.quote.render(&buffer);
self.bold.render(&buffer);
self.italic.render(&buffer);
self.pre.render(&buffer);
self.strike.render(&buffer);
self.underline.render(&buffer);
reference::render(&buffer, base, link_color, links);
hr::render(text_view);
// Cleanup unformatted escape chars
for e in ESCAPE_ENTRIES {
let mut cursor = buffer.start_iter();
while let Some((mut match_start, mut match_end)) =
cursor.forward_search(e, TextSearchFlags::CASE_INSENSITIVE, None)
{
if match_end.backward_cursor_positions(1) {
buffer.delete(&mut match_start, &mut match_end)
}
cursor = match_end;
}
}
// Render placeholders
self.code.render(&buffer);
// Format document title string
title.map(|mut s| {
s = bold::strip_tags(&s);
s = hr::strip_tags(&s);
s = italic::strip_tags(&s);
s = pre::strip_tags(&s);
s = reference::strip_tags(&s);
s = strike::strip_tags(&s);
s = underline::strip_tags(&s);
for e in ESCAPE_ENTRIES {
s = s.replace(e, &e[1..]);
}
s
})
}
}
/// Shared URL #fragment logic (for the Header tags ref)
pub fn format_header_fragment(value: &str) -> GString {
Uri::escape_string(&value.to_lowercase().replace(" ", "-"), None, true)
}
const ESCAPE_ENTRIES: &[&str] = &[
"\\\n", "\\\\", "\\>", "\\`", "\\!", "\\[", "\\]", "\\(", "\\)", "\\*", "\\#", "\\~", "\\_",
"\\-",
];
#[test]
fn test_escape_entries() {
let mut set = std::collections::HashSet::new();
for e in ESCAPE_ENTRIES {
assert_eq!(e.len(), 2);
assert!(set.insert(*e))
}
}

View file

@ -0,0 +1,104 @@
use gtk::{
TextBuffer, TextTag,
WrapMode::Word,
prelude::{TextBufferExt, TextBufferExtManual},
};
use regex::Regex;
const REGEX_BOLD: &str = r"(\*\*|__)(?P<text>[^\*_]*)(\*\*|__)";
pub struct Bold(TextTag);
impl Bold {
pub fn new() -> Self {
Self(TextTag::builder().weight(600).wrap_mode(Word).build())
}
/// Apply **bold**/__bold__ `Tag` to given `TextBuffer`
pub fn render(&self, buffer: &TextBuffer) {
assert!(buffer.tag_table().add(&self.0));
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_BOLD)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
if start_char_offset > 0
&& buffer
.text(
&buffer.iter_at_offset(start_char_offset - 1),
&end_iter,
false,
)
.contains("\\")
{
continue;
}
let mut tags = start_iter.tags();
tags.push(self.0.clone());
buffer.delete(&mut start_iter, &mut end_iter);
buffer.insert_with_tags(
&mut start_iter,
&cap["text"],
&tags.iter().collect::<Vec<&TextTag>>(),
)
}
}
}
pub fn strip_tags(value: &str) -> String {
let mut result = String::from(value);
for cap in Regex::new(REGEX_BOLD).unwrap().captures_iter(value) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
result
}
#[test]
fn test_strip_tags() {
const VALUE: &str =
"Some **bold 1** and **bold 2** and __bold 3__ and *italic 1* and _italic 2_";
let mut result = String::from(VALUE);
for cap in Regex::new(REGEX_BOLD).unwrap().captures_iter(VALUE) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
assert_eq!(
result,
"Some bold 1 and bold 2 and bold 3 and *italic 1* and _italic 2_"
)
}
#[test]
fn test_regex() {
let cap: Vec<_> = Regex::new(REGEX_BOLD)
.unwrap()
.captures_iter(
"Some **bold 1** and **bold 2** and __bold 3__ and *italic 1* and _italic 2_",
)
.collect();
assert_eq!(cap.len(), 3);
let mut c = cap.into_iter();
assert_eq!(&c.next().unwrap()["text"], "bold 1");
assert_eq!(&c.next().unwrap()["text"], "bold 2");
assert_eq!(&c.next().unwrap()["text"], "bold 3");
}

View file

@ -0,0 +1,128 @@
mod ansi;
mod syntax;
use gtk::{
TextBuffer, TextSearchFlags, TextTag, WrapMode,
glib::{GString, uuid_string_random},
prelude::{TextBufferExt, TextBufferExtManual},
};
use regex::Regex;
use std::collections::HashMap;
use syntax::Syntax;
const REGEX_CODE: &str = r"(?s)```[ \t]*(?P<alt>.*?)\n(?P<data>.*?)```";
struct Entry {
alt: Option<String>,
data: String,
}
pub struct Code {
index: HashMap<GString, Entry>,
alt: TextTag,
}
impl Code {
pub fn new() -> Self {
Self {
index: HashMap::new(),
alt: TextTag::builder()
.pixels_above_lines(4)
.pixels_below_lines(8)
.weight(500)
.wrap_mode(WrapMode::None)
.build(),
}
}
/// Collect all code blocks into `Self.index` (to prevent formatting)
pub fn collect(&mut self, buffer: &TextBuffer) {
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_CODE)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let id = uuid_string_random();
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
buffer.delete(&mut start_iter, &mut end_iter);
buffer.insert_with_tags(&mut start_iter, &id, &[]);
assert!(
self.index
.insert(
id,
Entry {
alt: alt(cap["alt"].into()).map(|s| s.into()),
data: cap["data"].into(),
},
)
.is_none()
)
}
}
/// Apply code `Tag` to given `TextBuffer` using `Self.index`
pub fn render(&mut self, buffer: &TextBuffer) {
let syntax = Syntax::new();
assert!(buffer.tag_table().add(&self.alt));
for (k, v) in self.index.iter() {
while let Some((mut m_start, mut m_end)) =
buffer
.start_iter()
.forward_search(k, TextSearchFlags::VISIBLE_ONLY, None)
{
buffer.delete(&mut m_start, &mut m_end);
if let Some(ref alt) = v.alt {
buffer.insert_with_tags(&mut m_start, &format!("{alt}\n"), &[&self.alt])
}
match syntax.highlight(&v.data, v.alt.as_ref()) {
Ok(highlight) => {
for (syntax_tag, entity) in highlight {
assert!(buffer.tag_table().add(&syntax_tag));
buffer.insert_with_tags(&mut m_start, &entity, &[&syntax_tag])
}
}
Err(_) => {
// Try ANSI/SGR format (terminal emulation) @TODO optional
for (syntax_tag, entity) in ansi::format(&v.data) {
assert!(buffer.tag_table().add(&syntax_tag));
buffer.insert_with_tags(&mut m_start, &entity, &[&syntax_tag])
}
}
}
}
}
}
}
fn alt(value: Option<&str>) -> Option<&str> {
value.map(|m| m.trim()).filter(|s| !s.is_empty())
}
#[test]
fn test_regex() {
let cap: Vec<_> = Regex::new(REGEX_CODE)
.unwrap()
.captures_iter("Some ``` alt text\ncode line 1\ncode line 2``` and ```\ncode line 3\ncode line 4``` with ![img](https://link.com)")
.collect();
let first = cap.first().unwrap();
assert_eq!(alt(first.name("alt").map(|m| m.as_str())), Some("alt text"));
assert_eq!(&first["data"], "code line 1\ncode line 2");
let second = cap.get(1).unwrap();
assert_eq!(alt(second.name("alt").map(|m| m.as_str())), None);
assert_eq!(&second["data"], "code line 3\ncode line 4");
}

View file

@ -0,0 +1,33 @@
mod rgba;
mod tag;
use tag::Tag;
use ansi_parser::{AnsiParser, AnsiSequence, Output};
use gtk::{TextTag, prelude::TextTagExt};
/// Apply ANSI/SGR format to new buffer
pub fn format(source_code: &str) -> Vec<(TextTag, String)> {
let mut buffer = Vec::new();
let mut tag = Tag::new();
for ref entity in source_code.ansi_parse() {
if let Output::Escape(AnsiSequence::SetGraphicsMode(color)) = entity
&& color.len() > 1
{
if color[0] == 38 {
tag.text_tag
.set_foreground_rgba(rgba::default(*color.last().unwrap()).as_ref());
} else {
tag.text_tag
.set_background_rgba(rgba::default(*color.last().unwrap()).as_ref());
}
}
if let Output::TextBlock(text) = entity {
buffer.push((tag.text_tag, text.to_string()));
tag = Tag::new();
}
}
buffer
}

View file

@ -0,0 +1,256 @@
use gtk::gdk::RGBA;
/// Default RGBa palette for ANSI terminal emulation
pub fn default(color: u8) -> Option<RGBA> {
match color {
7 => Some(RGBA::new(0.854, 0.854, 0.854, 1.0)),
8 => Some(RGBA::new(0.424, 0.424, 0.424, 1.0)),
10 => Some(RGBA::new(0.0, 1.0, 0.0, 1.0)),
11 => Some(RGBA::new(1.0, 1.0, 0.0, 1.0)),
12 => Some(RGBA::new(0.0, 0.0, 1.0, 1.0)),
13 => Some(RGBA::new(1.0, 0.0, 1.0, 1.0)),
14 => Some(RGBA::new(0.0, 1.0, 1.0, 1.0)),
15 => Some(RGBA::new(1.0, 1.0, 1.0, 1.0)),
16 => Some(RGBA::new(0.0, 0.0, 0.0, 1.0)),
17 => Some(RGBA::new(0.0, 0.020, 0.373, 1.0)),
18 => Some(RGBA::new(0.0, 0.031, 0.529, 1.0)),
19 => Some(RGBA::new(0.0, 0.0, 0.686, 1.0)),
20 => Some(RGBA::new(0.0, 0.0, 0.823, 1.0)),
21 => Some(RGBA::new(0.0, 0.0, 1.0, 1.0)),
22 => Some(RGBA::new(0.0, 0.373, 0.0, 1.0)),
23 => Some(RGBA::new(0.0, 0.373, 0.373, 1.0)),
24 => Some(RGBA::new(0.0, 0.373, 0.529, 1.0)),
25 => Some(RGBA::new(0.0, 0.373, 0.686, 1.0)),
26 => Some(RGBA::new(0.0, 0.373, 0.823, 1.0)),
27 => Some(RGBA::new(0.0, 0.373, 1.0, 1.0)),
28 => Some(RGBA::new(0.0, 0.533, 0.0, 1.0)),
29 => Some(RGBA::new(0.0, 0.533, 0.373, 1.0)),
30 => Some(RGBA::new(0.0, 0.533, 0.533, 1.0)),
31 => Some(RGBA::new(0.0, 0.533, 0.686, 1.0)),
32 => Some(RGBA::new(0.0, 0.533, 0.823, 1.0)),
33 => Some(RGBA::new(0.0, 0.533, 1.0, 1.0)),
34 => Some(RGBA::new(0.039, 0.686, 0.0, 1.0)),
35 => Some(RGBA::new(0.039, 0.686, 0.373, 1.0)),
36 => Some(RGBA::new(0.039, 0.686, 0.529, 1.0)),
37 => Some(RGBA::new(0.039, 0.686, 0.686, 1.0)),
38 => Some(RGBA::new(0.039, 0.686, 0.823, 1.0)),
39 => Some(RGBA::new(0.039, 0.686, 1.0, 1.0)),
40 => Some(RGBA::new(0.0, 0.843, 0.0, 1.0)),
41 => Some(RGBA::new(0.0, 0.843, 0.373, 1.0)),
42 => Some(RGBA::new(0.0, 0.843, 0.529, 1.0)),
43 => Some(RGBA::new(0.0, 0.843, 0.686, 1.0)),
44 => Some(RGBA::new(0.0, 0.843, 0.843, 1.0)),
45 => Some(RGBA::new(0.0, 0.843, 1.0, 1.0)),
46 => Some(RGBA::new(0.0, 1.0, 0.0, 1.0)),
47 => Some(RGBA::new(0.0, 1.0, 0.373, 1.0)),
48 => Some(RGBA::new(0.0, 1.0, 0.529, 1.0)),
49 => Some(RGBA::new(0.0, 1.0, 0.686, 1.0)),
50 => Some(RGBA::new(0.0, 1.0, 0.843, 1.0)),
51 => Some(RGBA::new(0.0, 1.0, 1.0, 1.0)),
52 => Some(RGBA::new(0.373, 0.0, 0.0, 1.0)),
53 => Some(RGBA::new(0.373, 0.0, 0.373, 1.0)),
54 => Some(RGBA::new(0.373, 0.0, 0.529, 1.0)),
55 => Some(RGBA::new(0.373, 0.0, 0.686, 1.0)),
56 => Some(RGBA::new(0.373, 0.0, 0.843, 1.0)),
57 => Some(RGBA::new(0.373, 0.0, 1.0, 1.0)),
58 => Some(RGBA::new(0.373, 0.373, 0.0, 1.0)),
59 => Some(RGBA::new(0.373, 0.373, 0.373, 1.0)),
60 => Some(RGBA::new(0.373, 0.373, 0.529, 1.0)),
61 => Some(RGBA::new(0.373, 0.373, 0.686, 1.0)),
62 => Some(RGBA::new(0.373, 0.373, 0.843, 1.0)),
63 => Some(RGBA::new(0.373, 0.373, 1.0, 1.0)),
64 => Some(RGBA::new(0.373, 0.529, 0.0, 1.0)),
65 => Some(RGBA::new(0.373, 0.529, 0.373, 1.0)),
66 => Some(RGBA::new(0.373, 0.529, 0.529, 1.0)),
67 => Some(RGBA::new(0.373, 0.529, 0.686, 1.0)),
68 => Some(RGBA::new(0.373, 0.529, 0.843, 1.0)),
69 => Some(RGBA::new(0.373, 0.529, 1.0, 1.0)),
70 => Some(RGBA::new(0.373, 0.686, 0.0, 1.0)),
71 => Some(RGBA::new(0.373, 0.686, 0.373, 1.0)),
72 => Some(RGBA::new(0.373, 0.686, 0.529, 1.0)),
73 => Some(RGBA::new(0.373, 0.686, 0.686, 1.0)),
74 => Some(RGBA::new(0.373, 0.686, 0.843, 1.0)),
75 => Some(RGBA::new(0.373, 0.686, 1.0, 1.0)),
76 => Some(RGBA::new(0.373, 0.843, 0.0, 1.0)),
77 => Some(RGBA::new(0.373, 0.843, 0.373, 1.0)),
78 => Some(RGBA::new(0.373, 0.843, 0.529, 1.0)),
79 => Some(RGBA::new(0.373, 0.843, 0.686, 1.0)),
80 => Some(RGBA::new(0.373, 0.843, 0.843, 1.0)),
81 => Some(RGBA::new(0.373, 0.843, 1.0, 1.0)),
82 => Some(RGBA::new(0.373, 1.0, 0.0, 1.0)),
83 => Some(RGBA::new(0.373, 1.0, 0.373, 1.0)),
84 => Some(RGBA::new(0.373, 1.0, 0.529, 1.0)),
85 => Some(RGBA::new(0.373, 1.0, 0.686, 1.0)),
86 => Some(RGBA::new(0.373, 1.0, 0.843, 1.0)),
87 => Some(RGBA::new(0.373, 1.0, 1.0, 1.0)),
88 => Some(RGBA::new(0.529, 0.0, 0.0, 1.0)),
89 => Some(RGBA::new(0.529, 0.0, 0.373, 1.0)),
90 => Some(RGBA::new(0.529, 0.0, 0.529, 1.0)),
91 => Some(RGBA::new(0.529, 0.0, 0.686, 1.0)),
92 => Some(RGBA::new(0.529, 0.0, 0.843, 1.0)),
93 => Some(RGBA::new(0.529, 0.0, 1.0, 1.0)),
94 => Some(RGBA::new(0.529, 0.373, 0.0, 1.0)),
95 => Some(RGBA::new(0.529, 0.373, 0.373, 1.0)),
96 => Some(RGBA::new(0.529, 0.373, 0.529, 1.0)),
97 => Some(RGBA::new(0.529, 0.373, 0.686, 1.0)),
98 => Some(RGBA::new(0.529, 0.373, 0.843, 1.0)),
99 => Some(RGBA::new(0.529, 0.373, 1.0, 1.0)),
100 => Some(RGBA::new(0.529, 0.529, 0.0, 1.0)),
101 => Some(RGBA::new(0.529, 0.529, 0.373, 1.0)),
102 => Some(RGBA::new(0.529, 0.529, 0.529, 1.0)),
103 => Some(RGBA::new(0.529, 0.529, 0.686, 1.0)),
104 => Some(RGBA::new(0.529, 0.529, 0.843, 1.0)),
105 => Some(RGBA::new(0.529, 0.529, 1.0, 1.0)),
106 => Some(RGBA::new(0.533, 0.686, 0.0, 1.0)),
107 => Some(RGBA::new(0.533, 0.686, 0.373, 1.0)),
108 => Some(RGBA::new(0.533, 0.686, 0.529, 1.0)),
109 => Some(RGBA::new(0.533, 0.686, 0.686, 1.0)),
110 => Some(RGBA::new(0.533, 0.686, 0.843, 1.0)),
111 => Some(RGBA::new(0.533, 0.686, 1.0, 1.0)),
112 => Some(RGBA::new(0.533, 0.843, 0.0, 1.0)),
113 => Some(RGBA::new(0.533, 0.843, 0.373, 1.0)),
114 => Some(RGBA::new(0.533, 0.843, 0.529, 1.0)),
115 => Some(RGBA::new(0.533, 0.843, 0.686, 1.0)),
116 => Some(RGBA::new(0.533, 0.843, 0.843, 1.0)),
117 => Some(RGBA::new(0.533, 0.843, 1.0, 1.0)),
118 => Some(RGBA::new(0.533, 1.0, 0.0, 1.0)),
119 => Some(RGBA::new(0.533, 1.0, 0.373, 1.0)),
120 => Some(RGBA::new(0.533, 1.0, 0.529, 1.0)),
121 => Some(RGBA::new(0.533, 1.0, 0.686, 1.0)),
122 => Some(RGBA::new(0.533, 1.0, 0.843, 1.0)),
123 => Some(RGBA::new(0.533, 1.0, 1.0, 1.0)),
124 => Some(RGBA::new(0.686, 0.0, 0.0, 1.0)),
125 => Some(RGBA::new(0.686, 0.0, 0.373, 1.0)),
126 => Some(RGBA::new(0.686, 0.0, 0.529, 1.0)),
127 => Some(RGBA::new(0.686, 0.0, 0.686, 1.0)),
128 => Some(RGBA::new(0.686, 0.0, 0.843, 1.0)),
129 => Some(RGBA::new(0.686, 0.0, 1.0, 1.0)),
130 => Some(RGBA::new(0.686, 0.373, 0.0, 1.0)),
131 => Some(RGBA::new(0.686, 0.373, 0.373, 1.0)),
132 => Some(RGBA::new(0.686, 0.373, 0.529, 1.0)),
133 => Some(RGBA::new(0.686, 0.373, 0.686, 1.0)),
134 => Some(RGBA::new(0.686, 0.373, 0.843, 1.0)),
135 => Some(RGBA::new(0.686, 0.373, 1.0, 1.0)),
136 => Some(RGBA::new(0.686, 0.529, 0.0, 1.0)),
137 => Some(RGBA::new(0.686, 0.529, 0.373, 1.0)),
138 => Some(RGBA::new(0.686, 0.529, 0.529, 1.0)),
139 => Some(RGBA::new(0.686, 0.529, 0.686, 1.0)),
140 => Some(RGBA::new(0.686, 0.529, 0.843, 1.0)),
141 => Some(RGBA::new(0.686, 0.529, 1.0, 1.0)),
142 => Some(RGBA::new(0.686, 0.686, 0.0, 1.0)),
143 => Some(RGBA::new(0.686, 0.686, 0.373, 1.0)),
144 => Some(RGBA::new(0.686, 0.686, 0.529, 1.0)),
145 => Some(RGBA::new(0.686, 0.686, 0.686, 1.0)),
146 => Some(RGBA::new(0.686, 0.686, 0.843, 1.0)),
147 => Some(RGBA::new(0.686, 0.686, 1.0, 1.0)),
148 => Some(RGBA::new(0.686, 0.843, 0.0, 1.0)),
149 => Some(RGBA::new(0.686, 0.843, 0.373, 1.0)),
150 => Some(RGBA::new(0.686, 0.843, 0.529, 1.0)),
151 => Some(RGBA::new(0.686, 0.843, 0.686, 1.0)),
152 => Some(RGBA::new(0.686, 0.843, 0.843, 1.0)),
153 => Some(RGBA::new(0.686, 0.843, 1.0, 1.0)),
154 => Some(RGBA::new(0.686, 1.0, 0.0, 1.0)),
155 => Some(RGBA::new(0.686, 1.0, 0.373, 1.0)),
156 => Some(RGBA::new(0.686, 1.0, 0.529, 1.0)),
157 => Some(RGBA::new(0.686, 1.0, 0.686, 1.0)),
158 => Some(RGBA::new(0.686, 1.0, 0.843, 1.0)),
159 => Some(RGBA::new(0.686, 1.0, 1.0, 1.0)),
160 => Some(RGBA::new(0.847, 0.0, 0.0, 1.0)),
161 => Some(RGBA::new(0.847, 0.0, 0.373, 1.0)),
162 => Some(RGBA::new(0.847, 0.0, 0.529, 1.0)),
163 => Some(RGBA::new(0.847, 0.0, 0.686, 1.0)),
164 => Some(RGBA::new(0.847, 0.0, 0.843, 1.0)),
165 => Some(RGBA::new(0.847, 0.0, 1.0, 1.0)),
166 => Some(RGBA::new(0.847, 0.373, 0.0, 1.0)),
167 => Some(RGBA::new(0.847, 0.373, 0.373, 1.0)),
168 => Some(RGBA::new(0.847, 0.373, 0.529, 1.0)),
169 => Some(RGBA::new(0.847, 0.373, 0.686, 1.0)),
170 => Some(RGBA::new(0.847, 0.373, 0.843, 1.0)),
171 => Some(RGBA::new(0.847, 0.373, 1.0, 1.0)),
172 => Some(RGBA::new(0.847, 0.529, 0.0, 1.0)),
173 => Some(RGBA::new(0.847, 0.529, 0.373, 1.0)),
174 => Some(RGBA::new(0.847, 0.529, 0.529, 1.0)),
175 => Some(RGBA::new(0.847, 0.529, 0.686, 1.0)),
176 => Some(RGBA::new(0.847, 0.529, 0.843, 1.0)),
177 => Some(RGBA::new(0.847, 0.529, 1.0, 1.0)),
178 => Some(RGBA::new(0.847, 0.686, 0.0, 1.0)),
179 => Some(RGBA::new(0.847, 0.686, 0.373, 1.0)),
180 => Some(RGBA::new(0.847, 0.686, 0.529, 1.0)),
181 => Some(RGBA::new(0.847, 0.686, 0.686, 1.0)),
182 => Some(RGBA::new(0.847, 0.686, 0.843, 1.0)),
183 => Some(RGBA::new(0.847, 0.686, 1.0, 1.0)),
184 => Some(RGBA::new(0.847, 0.843, 0.0, 1.0)),
185 => Some(RGBA::new(0.847, 0.843, 0.373, 1.0)),
186 => Some(RGBA::new(0.847, 0.843, 0.529, 1.0)),
187 => Some(RGBA::new(0.847, 0.843, 0.686, 1.0)),
188 => Some(RGBA::new(0.847, 0.843, 0.843, 1.0)),
189 => Some(RGBA::new(0.847, 0.843, 1.0, 1.0)),
190 => Some(RGBA::new(0.847, 1.0, 0.0, 1.0)),
191 => Some(RGBA::new(0.847, 1.0, 0.373, 1.0)),
192 => Some(RGBA::new(0.847, 1.0, 0.529, 1.0)),
193 => Some(RGBA::new(0.847, 1.0, 0.686, 1.0)),
194 => Some(RGBA::new(0.847, 1.0, 0.843, 1.0)),
195 => Some(RGBA::new(0.847, 1.0, 1.0, 1.0)),
196 => Some(RGBA::new(1.0, 0.0, 0.0, 1.0)),
197 => Some(RGBA::new(1.0, 0.0, 0.373, 1.0)),
198 => Some(RGBA::new(1.0, 0.0, 0.529, 1.0)),
199 => Some(RGBA::new(1.0, 0.0, 0.686, 1.0)),
200 => Some(RGBA::new(1.0, 0.0, 0.843, 1.0)),
201 => Some(RGBA::new(1.0, 0.0, 1.0, 1.0)),
202 => Some(RGBA::new(1.0, 0.373, 0.0, 1.0)),
203 => Some(RGBA::new(1.0, 0.373, 0.373, 1.0)),
204 => Some(RGBA::new(1.0, 0.373, 0.529, 1.0)),
205 => Some(RGBA::new(1.0, 0.373, 0.686, 1.0)),
206 => Some(RGBA::new(1.0, 0.373, 0.843, 1.0)),
207 => Some(RGBA::new(1.0, 0.373, 1.0, 1.0)),
208 => Some(RGBA::new(1.0, 0.529, 0.0, 1.0)),
209 => Some(RGBA::new(1.0, 0.529, 0.373, 1.0)),
210 => Some(RGBA::new(1.0, 0.529, 0.529, 1.0)),
211 => Some(RGBA::new(1.0, 0.529, 0.686, 1.0)),
212 => Some(RGBA::new(1.0, 0.529, 0.843, 1.0)),
213 => Some(RGBA::new(1.0, 0.529, 1.0, 1.0)),
214 => Some(RGBA::new(1.0, 0.686, 0.0, 1.0)),
215 => Some(RGBA::new(1.0, 0.686, 0.373, 1.0)),
216 => Some(RGBA::new(1.0, 0.686, 0.529, 1.0)),
217 => Some(RGBA::new(1.0, 0.686, 0.686, 1.0)),
218 => Some(RGBA::new(1.0, 0.686, 0.843, 1.0)),
219 => Some(RGBA::new(1.0, 0.686, 1.0, 1.0)),
220 => Some(RGBA::new(1.0, 0.843, 0.0, 1.0)),
221 => Some(RGBA::new(1.0, 0.843, 0.373, 1.0)),
222 => Some(RGBA::new(1.0, 0.843, 0.529, 1.0)),
223 => Some(RGBA::new(1.0, 0.843, 0.686, 1.0)),
224 => Some(RGBA::new(1.0, 0.843, 0.843, 1.0)),
225 => Some(RGBA::new(1.0, 0.843, 1.0, 1.0)),
226 => Some(RGBA::new(1.0, 1.0, 0.0, 1.0)),
227 => Some(RGBA::new(1.0, 1.0, 0.373, 1.0)),
228 => Some(RGBA::new(1.0, 1.0, 0.529, 1.0)),
229 => Some(RGBA::new(1.0, 1.0, 0.686, 1.0)),
230 => Some(RGBA::new(1.0, 1.0, 0.843, 1.0)),
231 => Some(RGBA::new(1.0, 1.0, 1.0, 1.0)),
232 => Some(RGBA::new(0.031, 0.031, 0.031, 1.0)),
233 => Some(RGBA::new(0.071, 0.071, 0.071, 1.0)),
234 => Some(RGBA::new(0.110, 0.110, 0.110, 1.0)),
235 => Some(RGBA::new(0.149, 0.149, 0.149, 1.0)),
236 => Some(RGBA::new(0.188, 0.188, 0.188, 1.0)),
237 => Some(RGBA::new(0.227, 0.227, 0.227, 1.0)),
238 => Some(RGBA::new(0.267, 0.267, 0.267, 1.0)),
239 => Some(RGBA::new(0.306, 0.306, 0.306, 1.0)),
240 => Some(RGBA::new(0.345, 0.345, 0.345, 1.0)),
241 => Some(RGBA::new(0.384, 0.384, 0.384, 1.0)),
242 => Some(RGBA::new(0.424, 0.424, 0.424, 1.0)),
243 => Some(RGBA::new(0.462, 0.462, 0.462, 1.0)),
244 => Some(RGBA::new(0.502, 0.502, 0.502, 1.0)),
245 => Some(RGBA::new(0.541, 0.541, 0.541, 1.0)),
246 => Some(RGBA::new(0.580, 0.580, 0.580, 1.0)),
247 => Some(RGBA::new(0.620, 0.620, 0.620, 1.0)),
248 => Some(RGBA::new(0.659, 0.659, 0.659, 1.0)),
249 => Some(RGBA::new(0.694, 0.694, 0.694, 1.0)),
250 => Some(RGBA::new(0.733, 0.733, 0.733, 1.0)),
251 => Some(RGBA::new(0.777, 0.777, 0.777, 1.0)),
252 => Some(RGBA::new(0.816, 0.816, 0.816, 1.0)),
253 => Some(RGBA::new(0.855, 0.855, 0.855, 1.0)),
254 => Some(RGBA::new(0.890, 0.890, 0.890, 1.0)),
255 => Some(RGBA::new(0.933, 0.933, 0.933, 1.0)),
_ => None,
}
}

View file

@ -0,0 +1,29 @@
use gtk::{TextTag, WrapMode};
/// Default [TextTag](https://docs.gtk.org/gtk4/class.TextTag.html) preset
/// for ANSI buffer
pub struct Tag {
pub text_tag: TextTag,
}
impl Default for Tag {
fn default() -> Self {
Self::new()
}
}
impl Tag {
// Constructors
/// Create new `Self`
pub fn new() -> Self {
Self {
text_tag: TextTag::builder()
.family("monospace") // @TODO
.left_margin(28)
.scale(0.81) // * the rounded `0.8` value crops text for some reason @TODO
.wrap_mode(WrapMode::None)
.build(),
}
}
}

View file

@ -0,0 +1,152 @@
pub mod error;
mod tag;
pub use error::Error;
use tag::Tag;
use adw::StyleManager;
use gtk::{
TextTag,
gdk::RGBA,
pango::{Style, Underline},
prelude::TextTagExt,
};
use syntect::{
easy::HighlightLines,
highlighting::{Color, FontStyle, ThemeSet},
parsing::{SyntaxReference, SyntaxSet},
};
/* Default theme
@TODO make optional
base16-ocean.dark
base16-eighties.dark
base16-mocha.dark
base16-ocean.light
InspiredGitHub
Solarized (dark)
Solarized (light)
*/
pub const DEFAULT_THEME_DARK: &str = "base16-eighties.dark";
pub const DEFAULT_THEME_LIGHT: &str = "InspiredGitHub";
pub struct Syntax {
syntax_set: SyntaxSet,
theme_set: ThemeSet,
}
impl Default for Syntax {
fn default() -> Self {
Self::new()
}
}
impl Syntax {
// Constructors
/// Create new `Self`
pub fn new() -> Self {
Self {
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
}
}
// Actions
/// Apply `Syntect` highlight to new buffer returned,
/// according to given `alt` and `source_code` content
pub fn highlight(
&self,
source_code: &str,
alt: Option<&String>,
) -> Result<Vec<(TextTag, String)>, Error> {
if let Some(value) = alt {
if let Some(reference) = self.syntax_set.find_syntax_by_name(value) {
return self.buffer(source_code, reference);
}
if let Some(reference) = self.syntax_set.find_syntax_by_token(value) {
return self.buffer(source_code, reference);
}
if let Some(reference) = self.syntax_set.find_syntax_by_path(value) {
return self.buffer(source_code, reference);
}
}
if let Some(reference) = self.syntax_set.find_syntax_by_first_line(source_code) {
return self.buffer(source_code, reference);
}
Err(Error::Parse)
}
fn buffer(
&self,
source: &str,
syntax_reference: &SyntaxReference,
) -> Result<Vec<(TextTag, String)>, Error> {
// Init new line buffer
let mut buffer = Vec::new();
// Apply syntect decorator
let mut ranges = HighlightLines::new(
syntax_reference,
&self.theme_set.themes[if StyleManager::default().is_dark() {
DEFAULT_THEME_DARK
} else {
DEFAULT_THEME_LIGHT
}], // @TODO apply on env change
);
match ranges.highlight_line(source, &self.syntax_set) {
Ok(result) => {
// Build tags
for (style, entity) in result {
// Create new tag from default preset
let tag = Tag::new();
// Tuneup using syntect conversion
// tag.set_background_rgba(Some(&color_to_rgba(style.background)));
tag.text_tag
.set_foreground_rgba(Some(&color_to_rgba(style.foreground)));
tag.text_tag
.set_style(font_style_to_style(style.font_style));
tag.text_tag
.set_underline(font_style_to_underline(style.font_style));
// Append
buffer.push((tag.text_tag, entity.to_string()));
}
Ok(buffer)
}
Err(e) => Err(Error::Syntect(e)),
}
}
}
// Tools
fn color_to_rgba(color: Color) -> RGBA {
RGBA::new(
color.r as f32 / 255.0,
color.g as f32 / 255.0,
color.b as f32 / 255.0,
color.a as f32 / 255.0,
)
}
fn font_style_to_style(font_style: FontStyle) -> Style {
match font_style {
FontStyle::ITALIC => Style::Italic,
_ => Style::Normal,
}
}
fn font_style_to_underline(font_style: FontStyle) -> Underline {
match font_style {
FontStyle::UNDERLINE => Underline::Single,
_ => Underline::None,
}
}

View file

@ -0,0 +1,18 @@
use std::fmt::{Display, Formatter, Result};
#[derive(Debug)]
pub enum Error {
Parse,
Syntect(syntect::Error),
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter) -> Result {
match self {
Self::Parse => write!(f, "Parse error"),
Self::Syntect(e) => {
write!(f, "Syntect error: {e}")
}
}
}
}

View file

@ -0,0 +1,29 @@
use gtk::{TextTag, WrapMode};
/// Default [TextTag](https://docs.gtk.org/gtk4/class.TextTag.html) preset
/// for syntax highlight buffer
pub struct Tag {
pub text_tag: TextTag,
}
impl Default for Tag {
fn default() -> Self {
Self::new()
}
}
impl Tag {
// Constructors
/// Create new `Self`
pub fn new() -> Self {
Self {
text_tag: TextTag::builder()
.family("monospace") // @TODO
.left_margin(28)
.scale(0.81) // * the rounded `0.8` value crops text for some reason @TODO
.wrap_mode(WrapMode::None)
.build(),
}
}
}

View file

@ -0,0 +1,170 @@
use gtk::{
TextBuffer, TextTag, WrapMode,
glib::Uri,
prelude::{TextBufferExt, TextBufferExtManual},
};
use regex::Regex;
use std::collections::HashMap;
const REGEX_HEADER: &str = r"(?m)^(?P<level>#{1,6})\s+(?P<title>.*)$";
pub struct Header {
h1: TextTag,
h2: TextTag,
h3: TextTag,
h4: TextTag,
h5: TextTag,
h6: TextTag,
}
impl Header {
pub fn new() -> Self {
// * important to give the tag name here as used in the fragment search
Self {
h1: TextTag::builder()
.foreground("#2190a4") // @TODO optional
.name("h1")
.scale(1.6)
.sentence(true)
.weight(500)
.wrap_mode(WrapMode::Word)
.build(),
h2: TextTag::builder()
.foreground("#d56199") // @TODO optional
.name("h2")
.scale(1.4)
.sentence(true)
.weight(400)
.wrap_mode(WrapMode::Word)
.build(),
h3: TextTag::builder()
.foreground("#c88800") // @TODO optional
.name("h3")
.scale(1.2)
.sentence(true)
.weight(400)
.wrap_mode(WrapMode::Word)
.build(),
h4: TextTag::builder()
.foreground("#c88800") // @TODO optional
.name("h4")
.scale(1.1)
.sentence(true)
.weight(400)
.wrap_mode(WrapMode::Word)
.build(),
h5: TextTag::builder()
.foreground("#c88800") // @TODO optional
.name("h5")
.scale(1.0)
.sentence(true)
.weight(400)
.wrap_mode(WrapMode::Word)
.build(),
h6: TextTag::builder()
.foreground("#c88800") // @TODO optional
.name("h6")
.scale(1.0)
.sentence(true)
.weight(300)
.wrap_mode(WrapMode::Word)
.build(),
}
}
/// Apply title `Tag` to given `TextBuffer`
pub fn render(
&self,
buffer: &TextBuffer,
base: &Uri,
headers: &mut HashMap<TextTag, (String, Uri)>,
) -> Option<String> {
let mut raw_title = None;
let table = buffer.tag_table();
assert!(table.add(&self.h1));
assert!(table.add(&self.h2));
assert!(table.add(&self.h3));
assert!(table.add(&self.h4));
assert!(table.add(&self.h5));
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_HEADER)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.iter() {
if raw_title.is_none() && !cap["title"].trim().is_empty() {
raw_title = Some(cap["title"].into())
}
}
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
// Create unique phantom tag for each header
// * for the #fragment references implementation
let h = TextTag::new(Some(&format!("h{}", gtk::glib::uuid_string_random())));
assert!(table.add(&h));
// Render header in text buffer
buffer.delete(&mut start_iter, &mut end_iter);
match cap["level"].chars().count() {
1 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h1]),
2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h2]),
3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h3]),
4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h4]),
5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h5]),
6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h6]),
_ => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h]), // unexpected
}
// Register fragment reference
assert!(
headers
.insert(
h,
(
cap["title"].into(),
Uri::build(
base.flags(),
&base.scheme(),
base.userinfo().as_deref(),
base.host().as_deref(),
base.port(),
&base.path(),
base.query().as_deref(),
Some(&super::format_header_fragment(&cap["title"])),
)
),
)
.is_none()
)
}
raw_title
}
}
#[test]
fn test_regex_title() {
let cap: Vec<_> = Regex::new(REGEX_HEADER)
.unwrap()
.captures_iter(r"## Header ![alt](https://link.com)")
.collect();
let first = cap.first().unwrap();
assert_eq!(&first[0], "## Header ![alt](https://link.com)");
assert_eq!(&first["level"], "##");
assert_eq!(&first["title"], "Header ![alt](https://link.com)");
}

View file

@ -0,0 +1,93 @@
use gtk::{
Orientation, Separator, TextView,
glib::{ControlFlow, idle_add_local},
prelude::*,
};
use regex::Regex;
const REGEX_HR: &str = r"(?m)^(?P<hr>\\?[-]{3,})$";
/// Apply --- `Tag` to given `TextBuffer`
pub fn render(text_view: &TextView) {
let separator = Separator::builder()
.orientation(Orientation::Horizontal)
.build();
idle_add_local({
let text_view = text_view.clone();
let separator = separator.clone();
move || {
separator.set_width_request(text_view.width() - 18);
ControlFlow::Break
}
});
let buffer = text_view.buffer();
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_HR)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
if start_char_offset > 0
&& buffer
.text(
&buffer.iter_at_offset(start_char_offset - 1),
&end_iter,
false,
)
.contains("\\")
{
continue;
}
buffer.delete(&mut start_iter, &mut end_iter);
text_view.add_child_at_anchor(&separator, &buffer.create_child_anchor(&mut end_iter));
}
}
pub fn strip_tags(value: &str) -> String {
let mut result = String::from(value);
for cap in Regex::new(REGEX_HR).unwrap().captures_iter(value) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["hr"]);
}
}
result
}
#[test]
fn test_strip_tags() {
const VALUE: &str = "Some line\n---\nSome another-line with ![img](https://link.com)";
let mut result = String::from(VALUE);
for cap in Regex::new(REGEX_HR).unwrap().captures_iter(VALUE) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), "");
}
}
assert_eq!(
result,
"Some line\n\nSome another-line with ![img](https://link.com)"
)
}
#[test]
fn test_regex() {
let cap: Vec<_> = Regex::new(REGEX_HR)
.unwrap()
.captures_iter("Some line\n---\nSome another-line with ![img](https://link.com)")
.collect();
assert_eq!(&cap.first().unwrap()["hr"], "---");
}

View file

@ -0,0 +1,141 @@
use gtk::{
TextBuffer, TextTag,
WrapMode::Word,
pango::Style,
prelude::{TextBufferExt, TextBufferExtManual},
};
use regex::Regex;
const REGEX_ITALIC_1: &str = r"\*(?P<text>[^\*]*)\*";
const REGEX_ITALIC_2: &str = r"\b_(?P<text>[^_]*)_\b";
pub struct Italic(TextTag);
impl Italic {
pub fn new() -> Self {
Self(
TextTag::builder()
.style(Style::Italic)
.wrap_mode(Word)
.build(),
)
}
/// Apply *italic*/_italic_ `Tag` to given `TextBuffer`
/// * run after `Bold` tag!
pub fn render(&self, buffer: &TextBuffer) {
assert!(buffer.tag_table().add(&self.0));
render(self, buffer, REGEX_ITALIC_1);
render(self, buffer, REGEX_ITALIC_2);
}
}
fn render(this: &Italic, buffer: &TextBuffer, regex: &str) {
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(regex)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
if start_char_offset > 0
&& buffer
.text(
&buffer.iter_at_offset(start_char_offset - 1),
&end_iter,
false,
)
.contains("\\")
{
continue;
}
let mut tags = start_iter.tags();
tags.push(this.0.clone());
buffer.delete(&mut start_iter, &mut end_iter);
buffer.insert_with_tags(
&mut start_iter,
&cap["text"],
&tags.iter().collect::<Vec<&TextTag>>(),
)
}
}
/// * run after `Bold` tag!
pub fn strip_tags(value: &str) -> String {
let mut s = String::from(value);
for cap in Regex::new(REGEX_ITALIC_1).unwrap().captures_iter(value) {
if let Some(m) = cap.get(0) {
s = s.replace(m.as_str(), &cap["text"]);
}
}
for cap in Regex::new(REGEX_ITALIC_2).unwrap().captures_iter(value) {
if let Some(m) = cap.get(0) {
s = s.replace(m.as_str(), &cap["text"]);
}
}
s
}
#[test]
fn test_strip_tags() {
const S: &str = "Some *italic 1*\nand *italic 2* and _italic 3_";
{
let mut result = String::from(S);
for cap in Regex::new(REGEX_ITALIC_1).unwrap().captures_iter(S) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
assert_eq!(result, "Some italic 1\nand italic 2 and _italic 3_")
}
{
let mut result = String::from(S);
for cap in Regex::new(REGEX_ITALIC_2).unwrap().captures_iter(S) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
assert_eq!(result, "Some *italic 1*\nand *italic 2* and italic 3")
}
}
#[test]
fn test_regex() {
const S: &str = "Some *italic 1*\nand *italic 2* and _italic 3_";
{
let cap: Vec<_> = Regex::new(REGEX_ITALIC_1)
.unwrap()
.captures_iter(S)
.collect();
assert_eq!(cap.len(), 2);
let mut c = cap.into_iter();
assert_eq!(&c.next().unwrap()["text"], "italic 1");
assert_eq!(&c.next().unwrap()["text"], "italic 2");
}
{
let cap: Vec<_> = Regex::new(REGEX_ITALIC_2)
.unwrap()
.captures_iter(S)
.collect();
assert_eq!(cap.len(), 1);
let mut c = cap.into_iter();
assert_eq!(&c.next().unwrap()["text"], "italic 3");
}
}

View file

@ -0,0 +1,150 @@
use gtk::{
TextBuffer, TextTag,
prelude::{TextBufferExt, TextBufferExtManual},
};
use regex::Regex;
const REGEX_LIST: &str =
r"(?m)^(?P<level>[ \t]*)\*[ \t]+(?:(?P<state>\[[ xX]\])[ \t]+)?(?P<text>.*)";
struct State(bool);
impl State {
fn parse(value: Option<&str>) -> Option<Self> {
if let Some(state) = value
&& (state.starts_with("[ ]") || state.starts_with("[x]"))
{
return Some(Self(state.starts_with("[x]")));
}
None
}
fn is_checked(&self) -> bool {
self.0
}
}
struct Item {
pub level: usize,
pub state: Option<State>,
pub text: String,
}
impl Item {
fn parse(level: &str, state: Option<&str>, text: String) -> Self {
Self {
level: level.chars().count(),
state: State::parse(state),
text,
}
}
}
/// Apply * list item `Tag` to given `TextBuffer`
pub fn render(buffer: &TextBuffer) {
let state_tag = TextTag::builder().family("monospace").build();
assert!(buffer.tag_table().add(&state_tag));
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_LIST)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
buffer.delete(&mut start_iter, &mut end_iter);
let item = Item::parse(
&cap["level"],
cap.name("state").map(|m| m.as_str()),
cap["text"].into(),
);
buffer.insert_with_tags(
&mut start_iter,
&format!("{}", " ".repeat(item.level)),
&[],
);
if let Some(state) = item.state {
buffer.insert_with_tags(
&mut start_iter,
if state.is_checked() { "[x] " } else { "[ ] " },
&[&state_tag],
);
}
buffer.insert_with_tags(&mut start_iter, &item.text, &[]);
}
}
#[test]
fn test_regex() {
fn item(cap: &Vec<regex::Captures<'_>>, n: usize) -> Item {
let c = cap.get(n).unwrap();
Item::parse(
&c["level"],
c.name("state").map(|m| m.as_str()),
c["text"].into(),
)
}
let cap: Vec<_> = Regex::new(REGEX_LIST)
.unwrap()
.captures_iter("Some\n* list item 1\n * list item 1.1\n * list item 1.2\n* list item 2\nand\n* list item 3\n * [x] list item 3.1\n * [ ] list item 3.2\n* list item 4\n")
.collect();
{
let item = item(&cap, 0);
assert_eq!(item.level, 0);
assert!(item.state.is_none());
assert_eq!(item.text, "list item 1");
}
{
let item = item(&cap, 1);
assert_eq!(item.level, 2);
assert!(item.state.is_none());
assert_eq!(item.text, "list item 1.1");
}
{
let item = item(&cap, 2);
assert_eq!(item.level, 2);
assert!(item.state.is_none());
assert_eq!(item.text, "list item 1.2");
}
{
let item = item(&cap, 3);
assert_eq!(item.level, 0);
assert!(item.state.is_none());
assert_eq!(item.text, "list item 2");
}
{
let item = item(&cap, 4);
assert_eq!(item.level, 0);
assert!(item.state.is_none());
assert_eq!(item.text, "list item 3");
}
{
let item = item(&cap, 5);
assert_eq!(item.level, 2);
assert!(item.state.is_some_and(|this| this.is_checked()));
assert_eq!(item.text, "list item 3.1");
}
{
let item = item(&cap, 6);
assert_eq!(item.level, 2);
assert!(item.state.is_some_and(|this| !this.is_checked()));
assert_eq!(item.text, "list item 3.2");
}
{
let item = item(&cap, 7);
assert_eq!(item.level, 0);
assert!(item.state.is_none());
assert_eq!(item.text, "list item 4");
}
}

View file

@ -0,0 +1,105 @@
use gtk::{
TextBuffer, TextTag,
WrapMode::Word,
gdk::RGBA,
prelude::{TextBufferExt, TextBufferExtManual},
};
use regex::Regex;
const REGEX_PRE: &str = r"`(?P<text>[^`]*)`";
const TAG_FONT: &str = "monospace"; // @TODO
const TAG_SCALE: f64 = 0.9;
pub struct Pre(TextTag);
impl Pre {
pub fn new() -> Self {
Self(if adw::StyleManager::default().is_dark() {
TextTag::builder()
.background_rgba(&RGBA::new(255., 255., 255., 0.05))
.family(TAG_FONT)
.foreground("#e8e8e8")
.scale(TAG_SCALE)
.wrap_mode(Word)
.build()
} else {
TextTag::builder()
.background_rgba(&RGBA::new(0., 0., 0., 0.06))
.family(TAG_FONT)
.scale(TAG_SCALE)
.wrap_mode(Word)
.build()
})
}
/// Apply preformatted `Tag` to given `TextBuffer`
pub fn render(&self, buffer: &TextBuffer) {
assert!(buffer.tag_table().add(&self.0));
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_PRE)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
if start_char_offset > 0
&& buffer
.text(
&buffer.iter_at_offset(start_char_offset - 1),
&end_iter,
false,
)
.contains("\\")
{
continue;
}
buffer.delete(&mut start_iter, &mut end_iter);
buffer.insert_with_tags(&mut start_iter, &cap["text"], &[&self.0])
}
}
}
pub fn strip_tags(value: &str) -> String {
let mut result = String::from(value);
for cap in Regex::new(REGEX_PRE).unwrap().captures_iter(value) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
result
}
#[test]
fn test_strip_tags() {
const VALUE: &str = r"Some `pre 1` and `pre 2` with ![img](https://link.com)";
let mut result = String::from(VALUE);
for cap in Regex::new(REGEX_PRE).unwrap().captures_iter(VALUE) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
assert_eq!(result, "Some pre 1 and pre 2 with ![img](https://link.com)")
}
#[test]
fn test_regex() {
let cap: Vec<_> = Regex::new(REGEX_PRE)
.unwrap()
.captures_iter(r"Some `pre 1` and `pre 2` with ![img](https://link.com)")
.collect();
assert_eq!(&cap.first().unwrap()["text"], "pre 1");
assert_eq!(&cap.get(1).unwrap()["text"], "pre 2");
}

View file

@ -0,0 +1,66 @@
use gtk::{
TextBuffer, TextTag,
WrapMode::Word,
pango::Style::Italic,
prelude::{TextBufferExt, TextBufferExtManual},
};
use regex::Regex;
const REGEX_QUOTE: &str = r"(?m)^>(?:[ \t]*(?P<text>.*))?$";
pub struct Quote(TextTag);
impl Quote {
pub fn new() -> Self {
Self(
TextTag::builder()
.left_margin(28)
.wrap_mode(Word)
.style(Italic) // conflicts the italic tags decoration @TODO
.build(),
)
}
/// Apply quote `Tag` to given `TextBuffer`
pub fn render(&self, buffer: &TextBuffer) {
assert!(buffer.tag_table().add(&self.0));
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_QUOTE)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
buffer.delete(&mut start_iter, &mut end_iter);
buffer.insert_with_tags(&mut start_iter, &cap["text"], &[&self.0])
}
}
}
#[test]
fn test_regex() {
let cap: Vec<_> = Regex::new(REGEX_QUOTE).unwrap().captures_iter(
"> Some quote 1 with ![img](https://link.com)\n>\n> 2\\)Some quote 2 with text\nplain text\n> Some quote 3"
).collect();
let mut i = cap.into_iter();
assert_eq!(
&i.next().unwrap()["text"],
"Some quote 1 with ![img](https://link.com)"
);
assert!(&i.next().unwrap()["text"].is_empty());
assert_eq!(&i.next().unwrap()["text"], "2\\)Some quote 2 with text");
assert_eq!(&i.next().unwrap()["text"], "Some quote 3");
}

View file

@ -0,0 +1,361 @@
use gtk::{
TextBuffer, TextIter, TextTag, WrapMode,
gdk::RGBA,
glib::{Uri, UriFlags},
prelude::{TextBufferExt, TextBufferExtManual},
};
use regex::Regex;
use std::collections::HashMap;
const REGEX_LINK: &str = r"\[(?P<text>[^\]]*)\]\((?P<url>[^\)]+)\)";
const REGEX_IMAGE: &str = r"!\[(?P<alt>[^\]]*)\]\((?P<url>[^\)]+)\)";
const REGEX_IMAGE_LINK: &str =
r"\[(?P<is_img>!)\[(?P<alt>[^\]]*)\]\((?P<img_url>[^\)]+)\)\]\((?P<link_url>[^\)]+)\)";
struct Reference {
uri: Uri,
alt: String,
}
impl Reference {
/// Try construct new `Self` with given options
fn parse(address: &str, alt: Option<&str>, base: &Uri) -> Option<Self> {
// Convert address to the valid URI,
// resolve to absolute URL format if the target is relative
match Uri::resolve_relative(
Some(&base.to_string()),
// Relative scheme patch
// https://datatracker.ietf.org/doc/html/rfc3986#section-4.2
&match address.strip_prefix("//") {
Some(p) => {
let s = p.trim_start_matches(":");
format!(
"{}://{}",
base.scheme(),
if s.is_empty() {
format!("{}/", base.host().unwrap_or_default())
} else {
s.into()
}
)
}
None => address.into(),
},
UriFlags::NONE,
) {
Ok(ref url) => match Uri::parse(url, UriFlags::NONE) {
Ok(uri) => {
let mut a: Vec<&str> = Vec::with_capacity(2);
if uri.scheme() != base.scheme() {
a.push("");
}
match alt {
Some(text) => a.push(text),
None => a.push(url),
}
Some(Self {
uri,
alt: a.join(" "),
})
}
Err(_) => todo!(),
},
Err(_) => None,
}
}
/// Insert `Self` into the given `TextBuffer` by registering new `TextTag` created
fn into_buffer(
self,
buffer: &TextBuffer,
position: &mut TextIter,
link_color: &RGBA,
is_annotation: bool,
links: &mut HashMap<TextTag, Uri>,
) {
let a = if is_annotation {
buffer.insert_with_tags(position, " ", &[]);
TextTag::builder()
.foreground_rgba(link_color)
// .foreground_rgba(&adw::StyleManager::default().accent_color_rgba())
// @TODO adw 1.6 / ubuntu 24.10+
.pixels_above_lines(4)
.pixels_below_lines(4)
.rise(5000)
.scale(0.8)
.wrap_mode(WrapMode::Word)
.build()
} else {
TextTag::builder()
.foreground_rgba(link_color)
// .foreground_rgba(&adw::StyleManager::default().accent_color_rgba())
// @TODO adw 1.6 / ubuntu 24.10+
.sentence(true)
.wrap_mode(WrapMode::Word)
.build()
};
assert!(buffer.tag_table().add(&a));
let mut tags = position.tags(); // @TODO seems does not work :)
tags.push(a.clone());
buffer.insert_with_tags(position, &self.alt, &tags.iter().collect::<Vec<&TextTag>>());
links.insert(a, self.uri);
}
}
/// Image links `[![]()]()`
fn render_images_links(
buffer: &TextBuffer,
base: &Uri,
link_color: &RGBA,
links: &mut HashMap<TextTag, Uri>,
) {
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_IMAGE_LINK)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
if start_char_offset > 0
&& buffer
.text(
&buffer.iter_at_offset(start_char_offset - 1),
&end_iter,
false,
)
.contains("\\")
{
continue;
}
buffer.delete(&mut start_iter, &mut end_iter);
if let Some(this) = Reference::parse(
&cap["img_url"],
if cap["alt"].is_empty() {
None
} else {
Some(&cap["alt"])
},
base,
) {
this.into_buffer(buffer, &mut start_iter, link_color, false, links)
}
if let Some(this) = Reference::parse(&cap["link_url"], Some("1"), base) {
this.into_buffer(buffer, &mut start_iter, link_color, true, links)
}
}
}
pub fn render(
buffer: &TextBuffer,
base: &Uri,
link_color: &RGBA,
links: &mut HashMap<TextTag, Uri>,
) {
render_images_links(buffer, base, link_color, links);
render_images(buffer, base, link_color, links);
render_links(buffer, base, link_color, links)
}
/// Image tags `![]()`
fn render_images(
buffer: &TextBuffer,
base: &Uri,
link_color: &RGBA,
links: &mut HashMap<TextTag, Uri>,
) {
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_IMAGE)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
if start_char_offset > 0
&& buffer
.text(
&buffer.iter_at_offset(start_char_offset - 1),
&end_iter,
false,
)
.contains("\\")
{
continue;
}
buffer.delete(&mut start_iter, &mut end_iter);
if let Some(this) = Reference::parse(
&cap["url"],
if cap["alt"].is_empty() {
None
} else {
Some(&cap["alt"])
},
base,
) {
this.into_buffer(buffer, &mut start_iter, link_color, false, links)
}
}
}
/// Links `[]()`
fn render_links(
buffer: &TextBuffer,
base: &Uri,
link_color: &RGBA,
links: &mut HashMap<TextTag, Uri>,
) {
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_LINK)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
if start_char_offset > 0
&& buffer
.text(
&buffer.iter_at_offset(start_char_offset - 1),
&end_iter,
false,
)
.contains("\\")
{
continue;
}
buffer.delete(&mut start_iter, &mut end_iter);
if let Some(this) = Reference::parse(
&cap["url"],
if cap["text"].is_empty() {
None
} else {
Some(&cap["text"])
},
base,
) {
this.into_buffer(buffer, &mut start_iter, link_color, false, links)
}
}
}
pub fn strip_tags(value: &str) -> String {
let mut result = String::from(value);
for cap in Regex::new(REGEX_LINK).unwrap().captures_iter(value) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
result
}
#[test]
fn test_strip_tags() {
const VALUE: &str = r"Some text [link1](https://link1.com) [link2](https://link2.com)";
let mut result = String::from(VALUE);
for cap in Regex::new(REGEX_LINK).unwrap().captures_iter(VALUE) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
assert_eq!(result, "Some text link1 link2")
}
#[test]
fn test_regex_link() {
let cap: Vec<_> = Regex::new(REGEX_LINK)
.unwrap()
.captures_iter(r"[link1](https://link1.com) [link2](https://link2.com)")
.collect();
let first = cap.first().unwrap();
assert_eq!(&first[0], "[link1](https://link1.com)");
assert_eq!(&first["text"], "link1");
assert_eq!(&first["url"], "https://link1.com");
let second = cap.get(1).unwrap();
assert_eq!(&second[0], "[link2](https://link2.com)");
assert_eq!(&second["text"], "link2");
assert_eq!(&second["url"], "https://link2.com");
}
#[test]
fn test_regex_image_link() {
let cap: Vec<_> = Regex::new(
REGEX_IMAGE_LINK,
)
.unwrap().captures_iter(
r"[![image1](https://image1.com)](https://image2.com) [![image3](https://image3.com)](https://image4.com)"
).collect();
let first = cap.first().unwrap();
assert_eq!(
&first[0],
"[![image1](https://image1.com)](https://image2.com)"
);
assert_eq!(&first["alt"], "image1");
assert_eq!(&first["img_url"], "https://image1.com");
assert_eq!(&first["link_url"], "https://image2.com");
let second = cap.get(1).unwrap();
assert_eq!(
&second[0],
"[![image3](https://image3.com)](https://image4.com)"
);
assert_eq!(&second["alt"], "image3");
assert_eq!(&second["img_url"], "https://image3.com");
assert_eq!(&second["link_url"], "https://image4.com");
}
#[test]
fn test_regex_image() {
let cap: Vec<_> = Regex::new(REGEX_IMAGE)
.unwrap()
.captures_iter(r"![image1](https://image1.com) ![image2](https://image2.com)")
.collect();
let first = cap.first().unwrap();
assert_eq!(&first[0], "![image1](https://image1.com)");
assert_eq!(&first["alt"], "image1");
assert_eq!(&first["url"], "https://image1.com");
let second = cap.get(1).unwrap();
assert_eq!(&second[0], "![image2](https://image2.com)");
assert_eq!(&second["alt"], "image2");
assert_eq!(&second["url"], "https://image2.com");
}

View file

@ -0,0 +1,102 @@
use gtk::{
TextBuffer, TextTag,
WrapMode::Word,
prelude::{TextBufferExt, TextBufferExtManual},
};
use regex::Regex;
const REGEX_STRIKE: &str = r"~~(?P<text>[^~]*)~~";
pub struct Strike(TextTag);
impl Strike {
pub fn new() -> Self {
Self(
TextTag::builder()
.strikethrough(true)
.wrap_mode(Word)
.build(),
)
}
/// Apply ~~strike~~ `Tag` to given `TextBuffer`
pub fn render(&self, buffer: &TextBuffer) {
assert!(buffer.tag_table().add(&self.0));
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_STRIKE)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
if start_char_offset > 0
&& buffer
.text(
&buffer.iter_at_offset(start_char_offset - 1),
&end_iter,
false,
)
.contains("\\")
{
continue;
}
let mut tags = start_iter.tags();
tags.push(self.0.clone());
buffer.delete(&mut start_iter, &mut end_iter);
buffer.insert_with_tags(
&mut start_iter,
&cap["text"],
&tags.iter().collect::<Vec<&TextTag>>(),
)
}
}
}
pub fn strip_tags(value: &str) -> String {
let mut result = String::from(value);
for cap in Regex::new(REGEX_STRIKE).unwrap().captures_iter(value) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
result
}
#[test]
fn test_strip_tags() {
const VALUE: &str = r"Some ~~strike 1~~ and ~~strike 2~~ with ![img](https://link.com)";
let mut result = String::from(VALUE);
for cap in Regex::new(REGEX_STRIKE).unwrap().captures_iter(VALUE) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
assert_eq!(
result,
"Some strike 1 and strike 2 with ![img](https://link.com)"
)
}
#[test]
fn test_regex() {
let cap: Vec<_> = Regex::new(REGEX_STRIKE)
.unwrap()
.captures_iter(r"Some ~~strike 1~~ and ~~strike 2~~ with ![img](https://link.com)")
.collect();
assert_eq!(&cap.first().unwrap()["text"], "strike 1");
assert_eq!(&cap.get(1).unwrap()["text"], "strike 2");
}

View file

@ -0,0 +1,98 @@
use gtk::{
TextBuffer, TextTag,
WrapMode::Word,
pango::Underline::Single,
prelude::{TextBufferExt, TextBufferExtManual},
};
use regex::Regex;
const REGEX_UNDERLINE: &str = r"\b_(?P<text>[^_]*)_\b";
pub struct Underline(TextTag);
impl Underline {
pub fn new() -> Self {
Self(TextTag::builder().underline(Single).wrap_mode(Word).build())
}
/// Apply _underline_ `Tag` to given `TextBuffer`
pub fn render(&self, buffer: &TextBuffer) {
assert!(buffer.tag_table().add(&self.0));
let (start, end) = buffer.bounds();
let full_content = buffer.text(&start, &end, true).to_string();
let matches: Vec<_> = Regex::new(REGEX_UNDERLINE)
.unwrap()
.captures_iter(&full_content)
.collect();
for cap in matches.into_iter().rev() {
let full_match = cap.get(0).unwrap();
let start_char_offset = full_content[..full_match.start()].chars().count() as i32;
let end_char_offset = full_content[..full_match.end()].chars().count() as i32;
let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset);
if start_char_offset > 0
&& buffer
.text(
&buffer.iter_at_offset(start_char_offset - 1),
&end_iter,
false,
)
.contains("\\")
{
continue;
}
let mut tags = start_iter.tags();
tags.push(self.0.clone());
buffer.delete(&mut start_iter, &mut end_iter);
buffer.insert_with_tags(
&mut start_iter,
&cap["text"],
&tags.iter().collect::<Vec<&TextTag>>(),
)
}
}
}
pub fn strip_tags(value: &str) -> String {
let mut result = String::from(value);
for cap in Regex::new(REGEX_UNDERLINE).unwrap().captures_iter(value) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
result
}
#[test]
fn test_strip_tags() {
const VALUE: &str = r"Some _underline 1_ and _underline 2_ with ![img](https://link.com)";
let mut result = String::from(VALUE);
for cap in Regex::new(REGEX_UNDERLINE).unwrap().captures_iter(VALUE) {
if let Some(m) = cap.get(0) {
result = result.replace(m.as_str(), &cap["text"]);
}
}
assert_eq!(
result,
"Some underline 1 and underline 2 with ![img](https://link.com)"
)
}
#[test]
fn test_regex() {
let cap: Vec<_> = Regex::new(REGEX_UNDERLINE)
.unwrap()
.captures_iter(r"Some _underline 1_ and _underline 2_ with ![img](https://link.com)")
.collect();
assert_eq!(&cap.first().unwrap()["text"], "underline 1");
assert_eq!(&cap.get(1).unwrap()["text"], "underline 2");
}

View file

@ -121,7 +121,7 @@ impl Dialog for PreferencesDialog {
/// Lookup [MaxMind](https://www.maxmind.com) database /// Lookup [MaxMind](https://www.maxmind.com) database
fn l(profile: &Profile, socket_address: &SocketAddress) -> Option<String> { fn l(profile: &Profile, socket_address: &SocketAddress) -> Option<String> {
use maxminddb::{ use maxminddb::{
MaxMindDbError, Reader, Reader,
geoip2::{/*City,*/ Country}, geoip2::{/*City,*/ Country},
}; };
if !matches!( if !matches!(
@ -136,26 +136,16 @@ impl Dialog for PreferencesDialog {
Reader::open_readfile(c) Reader::open_readfile(c)
} }
.ok()?; .ok()?;
let lookup = {
let a: std::net::SocketAddr = socket_address.to_string().parse().unwrap(); let a: std::net::SocketAddr = socket_address.to_string().parse().unwrap();
let lookup: std::result::Result<Option<Country>, MaxMindDbError> = let c: Country = db.lookup(a.ip()).ok()?.decode().ok()??;
db.lookup(a.ip());
lookup
}
.ok()??;
lookup.country.map(|c| {
let mut b = Vec::new(); let mut b = Vec::new();
if let Some(iso_code) = c.iso_code { if let Some(iso_code) = c.country.iso_code {
b.push(iso_code) b.push(iso_code);
} }
if let Some(n) = c.names if let Some(name_en) = c.country.names.english {
&& let Some(s) = n.get("en") b.push(name_en);
{ }
b.push(s) b.join(", ").into()
} // @TODO multi-lang
// @TODO city DB
b.join(", ")
})
} }
p.add(&{ p.add(&{
let g = PreferencesGroup::builder().title("Remote").build(); let g = PreferencesGroup::builder().title("Remote").build();

View file

@ -151,7 +151,7 @@ pub fn select(
//profile_id: row.get(1)?, //profile_id: row.get(1)?,
opened: Event { opened: Event {
time: DateTime::from_unix_local(row.get(2)?).unwrap(), time: DateTime::from_unix_local(row.get(2)?).unwrap(),
count: row.get(3)?, count: row.get::<_, i64>(3)? as usize,
}, },
closed: closed(row.get(4)?, row.get(5)?), closed: closed(row.get(4)?, row.get(5)?),
request: row.get::<_, String>(6)?.into(), request: row.get::<_, String>(6)?.into(),