Compare commits

..

No commits in common. "master" and "0.12.2" have entirely different histories.

51 changed files with 214 additions and 5476 deletions

1
.gitignore vendored
View file

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

1806
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "Yoda"
version = "0.12.10"
version = "0.12.2"
edition = "2024"
license = "MIT"
readme = "README.md"
@ -22,7 +22,7 @@ features = ["gnome_46"]
[dependencies.sqlite]
package = "rusqlite"
version = "0.38.0"
version = "0.37.0"
[dependencies.sourceview]
package = "sourceview5"
@ -31,18 +31,16 @@ version = "0.10.0"
[dependencies]
ansi-parser = "0.9.1"
anyhow = "1.0.97"
ggemini = "0.20.0"
ggemini = "0.19.0"
ggemtext = "0.7.0"
indexmap = "2.10.0"
itertools = "0.14.0"
libspelling = "0.4.1"
maxminddb = "0.27.3"
maxminddb = "0.26.0"
openssl = "0.10.72"
plurify = "0.2.0"
r2d2 = "0.8.10"
r2d2_sqlite = "0.32.0"
regex = "1.12.3"
strip-tags = "0.1.0"
r2d2_sqlite = "0.31.0"
syntect = "5.2.0"
# development

View file

@ -1,17 +1,9 @@
# Yoda - Browser for [Gemini protocol](https://geminiprotocol.net)
![Yoda browser logo](https://raw.githubusercontent.com/YGGverse/Yoda/refs/heads/master/data/io.github.yggverse.Yoda.svg)
Privacy-oriented GTK 4 / Libadwaita client written in Rust.
The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol specification](https://geminiprotocol.net/docs/protocol-specification.gmi) and excludes third-party connections, that making it safe to use in combination with I2P. It also includes useful tools, such as connection details, optional DNS/Geo-IP features, flexible proxy configuration for use with modern IPv6 mesh networks like Yggdrasil, Mycelium, CJDNS, and others.
Yoda browser is primarily designed by and for experienced network users who care about their fingerprints and prefer to control every action manually. It does not preload tab content on app opening, does not run any background connections, does not incorporate web-like media preloading without user initiation, and does not automatically check for updates, even from 'official' servers. Additionally, it prevents auto-follow external redirection by default and requires manual confirmation, which is currently not clearly specified.
The Gemini protocol was designed as a minimalistic, tracking-resistant alternative to the Web, and Yoda embraces this philosophy by providing a straightforward graphical user interface (GUI) that is partially inspired by the Firefox UI, making it intuitively comfortable for regular users.
GTK 4 / Libadwaita client written in Rust
> [!IMPORTANT]
> Project in development, for stable version use checkpoint [releases](https://github.com/YGGverse/Yoda/releases)!
> Project in development, for stable version use crates.io release!
>
![image](https://github.com/user-attachments/assets/cfbbc3fb-61d2-4afd-a21f-8e36ee329941)
@ -135,9 +127,8 @@ The Gemini protocol was designed as a minimalistic, tracking-resistant alternati
#### Text
* [x] `text/gemini`
* [x] `text/markdown`
* [x] `text/nex`
* [x] `text/plain`
* [x] `text/nex`
#### Images
* [x] `image/gif`
@ -161,13 +152,13 @@ The Gemini protocol was designed as a minimalistic, tracking-resistant alternati
### Requirements
* Cairo `1.18+`
* GdkPixBuf `2.42+`
* Glib `2.80+`
* Gtk `4.14+`
* GtkSourceView `5.14+`
* libadwaita `1.5+` (Ubuntu `24.04+`)
* libspelling `0.1+`
* Cairo `1.18`
* GdkPixBuf `2.42`
* Glib `2.80`
* Gtk `4.14`
* GtkSourceView `5.14`
* libadwaita `1.5` (Ubuntu 24.04+)
* libspelling `0.1`
#### Debian
@ -235,7 +226,7 @@ flatpak-builder --force-clean build\
#### Contributors
![StandWithUkraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)
![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)
### Localization

View file

@ -3,7 +3,7 @@ Categories=GNOME;GTK;Network
Comment=Browser for Gemini protocol
Exec=Yoda
GenericName=Browser
Icon=io.github.yggverse.Yoda
#Icon=io.github.yggverse.Yoda
Keywords=Gnome;GTK;Gemini;Browser
Name=Yoda
StartupNotify=true

View file

@ -1,205 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<!-- Yoda - Browser for Gemini protocol (https://github.com/YGGverse/Yoda) -->
<svg
width="160"
height="160"
viewBox="0 0 160 160"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="io.github.yggverse.Yoda.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1">
<linearGradient
id="swatch27"
inkscape:swatch="solid">
<stop
style="stop-color:#008000;stop-opacity:1;"
offset="0"
id="stop27" />
</linearGradient>
<linearGradient
id="linearGradient16"
inkscape:collect="always">
<stop
style="stop-color:#008000;stop-opacity:1;"
offset="0.01007794"
id="stop16" />
<stop
style="stop-color:#008000;stop-opacity:0;"
offset="1"
id="stop17" />
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient16"
id="radialGradient17"
cx="108.1394"
cy="128.50734"
fx="108.1394"
fy="128.50734"
r="95.479652"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.81378396,0,0,0.81378396,-198.7296,-11.940724)" />
<linearGradient
inkscape:collect="always"
xlink:href="#swatch27"
id="linearGradient27"
x1="101.12355"
y1="177.4632"
x2="121.12355"
y2="177.4632"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.81378396,0,0,0.81378396,-9.3515326,-26.642049)" />
</defs>
<g
inkscape:label="nodes"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-12.659747,-33.027698)">
<circle
style="fill:#1a1a1a;stroke:url(#radialGradient17);stroke-width:16.2757;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path2"
cx="-110.7275"
cy="92.63649"
inkscape:label="node-m"
transform="rotate(-90)"
r="69.561974" />
<circle
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path6"
cx="74.352646"
cy="68.597336"
inkscape:label="node-l"
r="3.8494754" />
<circle
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path7"
cx="114.55827"
cy="78.434883"
inkscape:label="node-k"
r="5.1326337" />
<ellipse
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path8"
cx="44.198448"
cy="115.21876"
rx="3.6356158"
ry="3.4217558"
inkscape:label="node-j" />
<ellipse
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path9"
cx="69.861595"
cy="144.30368"
rx="5.7742133"
ry="5.5603533"
inkscape:label="node-h" />
<ellipse
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path10"
cx="145.14021"
cy="109.23068"
rx="3.207896"
ry="3.4217558"
inkscape:label="node-g" />
<ellipse
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path11"
cx="128.45915"
cy="125.91175"
rx="5.3464937"
ry="5.1326337"
inkscape:label="node-f" />
<ellipse
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path12"
cx="121.40178"
cy="150.93333"
rx="3.8494754"
ry="3.6356158"
inkscape:label="node-e" />
<ellipse
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path13"
cx="63.873543"
cy="112.65244"
rx="2.7801766"
ry="2.9940362"
inkscape:label="node-d" />
<ellipse
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path14"
cx="97.877228"
cy="58.118206"
rx="2.9940362"
ry="2.7801766"
inkscape:label="node-c" />
<circle
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path15"
cx="90.819847"
cy="88.058563"
inkscape:label="node-b"
r="2.352457" />
<ellipse
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path16"
cx="90.392136"
cy="69.452766"
rx="1.0692987"
ry="1.2831584"
inkscape:label="node-a" />
</g>
<path
style="fill:#ffffff;stroke:url(#linearGradient27);stroke-width:14.6481;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
d="M 81.079038,82.539999 V 153.0093"
id="path4"
inkscape:label="path-bottom" />
<path
style="fill:#ffffff;stroke:#008000;stroke-width:14.6481;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
d="M 84.968836,84.810391 22.226108,40.562796"
id="path1"
inkscape:label="path-top-left" />
<path
style="fill:#ffffff;stroke:#008000;stroke-width:14.6481;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
d="M 79.629398,83.845202 133.62386,43.920437"
id="path3"
inkscape:label="path-top-right" />
<circle
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path5-9"
cx="80.341171"
cy="80.117638"
inkscape:label="node-center"
r="14.648112" />
<circle
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path5-1"
cx="81.100639"
cy="145.35188"
inkscape:label="node-bottom"
r="14.648112" />
<circle
style="fill:#ffffff;stroke:#008000;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path5-0"
cx="23.956953"
cy="41.427452"
inkscape:label="node-top-left"
r="14.648112" />
<circle
style="fill:#ffffff;stroke:#ffffff;stroke-width:0;stroke-linecap:butt;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
id="path5"
cx="137.0753"
cy="41.843121"
inkscape:label="node-top-right"
r="14.648112" />
</svg>

Before

Width:  |  Height:  |  Size: 7 KiB

View file

@ -43,7 +43,7 @@ modules:
post-install:
- "install -Dm755 ./target/release/Yoda -t /app/bin"
- "install -Dm644 ./data/${FLATPAK_ID}.desktop -t /app/share/applications"
- "install -Dm644 ./data/${FLATPAK_ID}.svg -t /app/share/icons/hicolor/symbolic/apps"
# - "install -Dm644 ./data/${FLATPAK_ID}.svg -t /app/share/icons/hicolor/symbolic/apps"
sources:
- type: "dir"
path: "."

View file

@ -28,7 +28,6 @@ impl About for adw::AboutDialog {
];
adw::AboutDialog::builder()
.application_icon("io.github.yggverse.Yoda")
.application_name(env!("CARGO_PKG_NAME"))
.debug_info(debug.join("\n"))
.developer_name(env!("CARGO_PKG_DESCRIPTION"))

View file

@ -27,20 +27,10 @@ impl Bar for Box {
.orientation(Orientation::Horizontal)
.spacing(8)
.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(&MenuButton::menu((browser_action, window_action)));
g_box.append(&Control::right().window_controls)
}
g_box.append(&TabBar::tab(window_action, view));
g_box.append(&MenuButton::menu((browser_action, window_action)));
g_box.append(&Control::new().window_controls);
g_box
}
}

View file

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

View file

@ -14,12 +14,8 @@ impl Tab for TabBar {
fn tab(window_action: &Rc<WindowAction>, view: &TabView) -> Self {
TabBar::builder()
.autohide(false)
.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
.end_action_widget(&Button::append(window_action)) // @TODO find solution to append after tabs
.view(view)
.build()
}

View file

@ -10,6 +10,7 @@ use adw::{TabPage, TabView};
use anyhow::Result;
use gtk::{
Box, Orientation,
gio::Icon,
glib::Propagation,
prelude::{ActionExt, EditableExt, EntryExt, WidgetExt},
};
@ -43,6 +44,13 @@ impl Tab {
.menu_model(&gtk::gio::Menu::menu(window_action))
.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
tab_view.connect_setup_menu({
let index = index.clone();

View file

@ -83,35 +83,21 @@ impl Item {
let page = page.clone();
move |this, _| {
this.set_enabled(false);
match page.navigation.request.home() {
Some(uri) => {
let request = uri.to_string();
// prevent `changed` event extra emission
// but make sure the entry is always up-to-date
if page.navigation.request.entry.text() != request {
page.navigation.request.entry.set_text(&request)
}
client.handle(&request, true, false)
}
None => panic!(), // unexpected
if let Some(uri) = page.navigation.request.home() {
let request = uri.to_string();
page.navigation.request.entry.set_text(&request);
client.handle(&request, true, false);
}
}
});
action.load.connect_activate({
let c = client.clone();
let e = page.navigation.request.entry.clone();
let page = page.clone();
let client = client.clone();
move |request, is_snap_history, is_redirect| {
match request {
Some(request) => {
// prevent `changed` event extra emission
// but make sure the entry is always up-to-date
if e.text() != request {
e.set_text(&request)
}
c.handle(&request, is_snap_history, is_redirect)
}
None => panic!(), // unexpected
if let Some(request) = request {
page.navigation.request.entry.set_text(&request);
client.handle(&request, is_snap_history, is_redirect);
}
}
});
@ -158,13 +144,11 @@ impl Item {
});
// Handle immediately on request
if is_load && let Some(request) = request {
// prevent `changed` event extra emission
// but make sure the entry is always up-to-date
if page.navigation.request.entry.text() != request {
page.navigation.request.entry.set_text(request)
if let Some(request) = request {
page.navigation.request.entry.set_text(request);
if is_load {
client.handle(request, true, false)
}
client.handle(request, true, false)
}
Self {

View file

@ -71,31 +71,6 @@ impl File {
.set_mime(Some(content_type.to_string()));
}
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" => {
if matches!(*feature, Feature::Source) {
load_contents_async(file, cancellable, move |result| {
@ -119,18 +94,6 @@ 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 {
load_contents_async(file, cancellable, move |result| {
match result {
@ -144,31 +107,6 @@ 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" => {
match gtk::gdk::Texture::from_file(&file) {
Ok(texture) => {

View file

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

View file

@ -282,7 +282,11 @@ fn handle(
file_output_stream,
cancellable.clone(),
Priority::DEFAULT,
file_output_stream::Size::default(),
file_output_stream::Size {
chunk: 0x100000, // 1M bytes per chunk
limit: None, // unlimited
total: 0, // initial totals
},
(
// on chunk
{
@ -332,8 +336,9 @@ fn handle(
Priority::DEFAULT,
cancellable.clone(),
memory_input_stream::Size {
chunk: 0x400, // 1024 bytes chunk
limit: 0xfffff, // 1M limit
..memory_input_stream::Size::default()
total: 0, // initial totals
},
(
|_, _| {}, // on chunk (maybe nothing to count yet @TODO)
@ -357,8 +362,7 @@ fn handle(
page.content.to_text_source(data)
} else {
match m.as_str() {
"text/gemini" => page.content.to_text_gemini(&page.profile, &uri, data),
"text/markdown" => page.content.to_text_markdown(&page, &uri, data),
"text/gemini" => page.content.to_text_gemini(&uri, data),
"text/plain" => page.content.to_text_plain(data),
_ => panic!() // unexpected
}
@ -436,8 +440,9 @@ fn handle(
Priority::DEFAULT,
cancellable.clone(),
memory_input_stream::Size {
chunk: 0x400, // 1024 bytes chunk
limit: 0xA00000, // 10M limit
..memory_input_stream::Size::default()
total: 0, // initial totals
},
(
move |_, total| status.set_description(Some(&format!("Download: {}", total.bytes()))),
@ -555,7 +560,7 @@ fn handle(
.build();
b.connect_clicked({
let p = page.clone();
move |_| p.item_action.load.activate(Some(&u), true, true)
move |_| p.item_action.load.activate(Some(&u), false, true)
});
b
}));
@ -579,7 +584,7 @@ fn handle(
Redirect::Temporary { .. } => i.into_temporary_redirect(),
});
}
page.item_action.load.activate(Some(&t), true, true);
page.item_action.load.activate(Some(&t), false, true);
}
}
Err(e) => {

View file

@ -193,8 +193,9 @@ impl Nex {
Priority::DEFAULT,
cancellable.clone(),
ggemini::gio::memory_input_stream::Size {
chunk: 0x400, // 1024 bytes chunk
limit: 0xA00000, // 10M limit
..ggemini::gio::memory_input_stream::Size::default()
total: 0, // initial totals
},
(
{
@ -299,7 +300,7 @@ fn render(
} else if q.ends_with("/") {
p.content.to_text_nex(&u, d)
} else if q.ends_with(".gmi") || q.ends_with(".gemini") {
p.content.to_text_gemini(&p.profile, &u, d)
p.content.to_text_gemini(&u, d)
} else {
p.content.to_text_plain(d)
};
@ -342,7 +343,11 @@ fn download(s: SocketConnection, (p, u): (Rc<Page>, Uri), c: Cancellable) {
file_output_stream,
c.clone(),
Priority::DEFAULT,
file_output_stream::Size::default(),
file_output_stream::Size {
chunk: 0x100000, // 1M bytes per chunk
limit: None, // unlimited
total: 0, // initial totals
},
(
// on chunk
{

View file

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

View file

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

View file

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

View file

@ -5,23 +5,23 @@ mod icon;
mod syntax;
mod tag;
use super::{ItemAction, WindowAction};
use crate::{app::browser::window::action::Position, profile::Profile};
pub use error::Error;
use gutter::Gutter;
use icon::Icon;
use syntax::Syntax;
use tag::Tag;
use super::{ItemAction, WindowAction};
use crate::app::browser::window::action::Position;
use gtk::{
EventControllerMotion, GestureClick, TextBuffer, TextTag, TextView, TextWindowType,
UriLauncher, Window, WrapMode,
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA},
gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup},
glib::{Uri, uuid_string_random},
prelude::{PopoverExt, TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt},
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, RGBA},
gio::Cancellable,
glib::Uri,
prelude::{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 syntax::Syntax;
use tag::Tag;
pub const NEW_LINE: &str = "\n";
@ -36,7 +36,6 @@ impl Gemini {
/// Build new `Self`
pub fn build(
(window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>),
profile: &Rc<Profile>,
base: &Uri,
gemtext: &str,
) -> Result<Self, Error> {
@ -151,7 +150,11 @@ impl Gemini {
match syntax.highlight(&c.value, alt) {
Ok(highlight) => {
for (syntax_tag, entity) in highlight {
assert!(tag.text_tag_table.add(&syntax_tag));
// Register new tag
if !tag.text_tag_table.add(&syntax_tag) {
todo!()
}
// Append tag to buffer
buffer.insert_with_tags(
&mut buffer.end_iter(),
&entity,
@ -162,7 +165,11 @@ impl Gemini {
Err(_) => {
// Try ANSI/SGR format (terminal emulation) @TODO optional
for (syntax_tag, entity) in ansi::format(&c.value) {
assert!(tag.text_tag_table.add(&syntax_tag));
// Register new tag
if !tag.text_tag_table.add(&syntax_tag) {
todo!()
}
// Append tag to buffer
buffer.insert_with_tags(
&mut buffer.end_iter(),
&entity,
@ -179,7 +186,7 @@ impl Gemini {
// Skip other actions for this line
continue;
}
Err(_) => panic!(),
Err(_) => todo!(),
}
}
}
@ -210,10 +217,10 @@ impl Gemini {
// Is link
if let Some(link) = ggemtext::line::Link::parse(line) {
if let Some(uri) = link.uri(Some(base)) {
let mut alt = Vec::with_capacity(2);
let mut alt = Vec::new();
if uri.scheme() != base.scheme() {
alt.push(LINK_EXTERNAL_INDICATOR.to_string());
alt.push("".to_string());
}
alt.push(match link.alt {
@ -228,7 +235,9 @@ impl Gemini {
.wrap_mode(WrapMode::Word)
.build();
assert!(tag.text_tag_table.add(&a));
if !tag.text_tag_table.add(&a) {
panic!()
}
buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]);
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
@ -275,170 +284,14 @@ impl Gemini {
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
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 middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build();
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(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);
@ -455,92 +308,27 @@ impl Gemini {
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_current_tab(&uri.to_string(), &item_action);
}
}
}
}
});
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));
// Select link handler by scheme
return match uri.scheme().as_str() {
"gemini" | "titan" | "nex" | "file" => {
item_action.load.activate(Some(&uri.to_str()), true, false)
}
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()
// 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?
}
}
}
@ -562,7 +350,30 @@ impl Gemini {
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);
// Select link handler by scheme
return match uri.scheme().as_str() {
"gemini" | "titan" | "nex" | "file" => {
// Open new page in browser
window_action.append.activate_stateful_once(
Position::After,
Some(uri.to_string()),
false,
false,
true,
true,
);
}
// Scheme not supported, delegate
_ => UriLauncher::new(&uri.to_str()).launch(
Window::NONE,
Cancellable::NONE,
|result| {
if let Err(e) = result {
println!("{e}")
}
},
),
}; // @TODO common handler?
}
}
}
@ -621,59 +432,3 @@ 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

@ -1,610 +0,0 @@
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

@ -1,68 +0,0 @@
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

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

View file

@ -1,139 +0,0 @@
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

@ -1,104 +0,0 @@
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

@ -1,128 +0,0 @@
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

@ -1,33 +0,0 @@
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

@ -1,256 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,152 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,170 +0,0 @@
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

@ -1,93 +0,0 @@
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

@ -1,141 +0,0 @@
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

@ -1,150 +0,0 @@
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

@ -1,105 +0,0 @@
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

@ -1,66 +0,0 @@
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

@ -1,361 +0,0 @@
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

@ -1,102 +0,0 @@
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

@ -1,98 +0,0 @@
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

@ -75,9 +75,6 @@ impl Input {
}
pub fn set_new_titan(&self, on_send: impl Fn(titan::Header, Bytes, Box<dyn Fn()>) + 'static) {
self.update(Some(&gtk::Box::titan(MAX_CONTENT_HEIGHT, on_send)));
self.update(Some(&gtk::Box::titan(on_send)));
}
}
/// @TODO optional, maybe relative to the current window height in %
const MAX_CONTENT_HEIGHT: i32 = 280;

View file

@ -47,8 +47,8 @@ impl Response for Box {
.margin_end(MARGIN)
.margin_start(MARGIN)
.margin_top(MARGIN)
.orientation(Orientation::Vertical)
.spacing(SPACING)
.orientation(Orientation::Vertical)
.build();
g_box.append(&title);

View file

@ -23,11 +23,8 @@ impl Form for TextView {
// Init [libspelling](https://gitlab.gnome.org/GNOME/libspelling)
let checker = Checker::default();
let adapter = TextBufferAdapter::builder()
.buffer(&buffer)
.checker(&checker)
.enabled(true)
.build();
let adapter = TextBufferAdapter::new(&buffer, &checker);
adapter.set_enabled(true);
// Init main widget
let text_view = TextView::builder()
@ -39,12 +36,11 @@ impl Form for TextView {
.margin_bottom(MARGIN / 4)
.right_margin(MARGIN)
.top_margin(MARGIN)
.valign(gtk::Align::BaselineCenter)
.wrap_mode(WrapMode::Word)
.build();
text_view.insert_action_group("spelling", Some(&adapter));
text_view.set_size_request(-1, 36); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635)
text_view.set_size_request(-1, 38); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635)
// Init events
text_view.connect_realize(|this| {

View file

@ -16,17 +16,11 @@ use tab::Tab;
use text::Text;
pub trait Titan {
fn titan(
max_content_height: i32,
callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static,
) -> Self;
fn titan(callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static) -> Self;
}
impl Titan for gtk::Box {
fn titan(
max_content_height: i32,
callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static,
) -> Self {
fn titan(callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static) -> Self {
use gtk::{Label, glib::uuid_string_random, prelude::ButtonExt};
use std::rc::Rc;
@ -41,15 +35,7 @@ impl Titan for gtk::Box {
.show_border(false)
.build();
notebook.append_page(
&gtk::ScrolledWindow::builder()
.child(&text.text_view)
.max_content_height(max_content_height)
.propagate_natural_height(true)
.build(),
Some(&Label::tab("Text")),
);
notebook.append_page(&text.text_view, Some(&Label::tab("Text")));
notebook.append_page(&file.button, Some(&Label::tab("File")));
notebook.connect_switch_page({

View file

@ -16,11 +16,8 @@ impl Form for TextView {
// Init [libspelling](https://gitlab.gnome.org/GNOME/libspelling)
let checker = Checker::default();
let adapter = TextBufferAdapter::builder()
.buffer(&buffer)
.checker(&checker)
.enabled(true)
.build();
let adapter = TextBufferAdapter::new(&buffer, &checker);
adapter.set_enabled(true);
// Init main widget
@ -34,12 +31,12 @@ impl Form for TextView {
.left_margin(MARGIN)
.right_margin(MARGIN)
.top_margin(MARGIN)
.valign(gtk::Align::Fill)
.wrap_mode(WrapMode::Word)
.build()
};
text_view.insert_action_group("spelling", Some(&adapter));
text_view.set_size_request(-1, 38); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635)
// Init events
text_view.connect_realize(|this| {

View file

@ -60,10 +60,10 @@ impl Request {
};
let c = gtk::EventControllerKey::builder().build();
c.connect_key_pressed({
let e = entry.clone();
let s = suggestion.clone();
let entry = entry.clone();
let suggestion = suggestion.clone();
move |_, k, _, m| {
if s.is_visible()
if suggestion.is_visible()
&& !matches!(
m,
ModifierType::SHIFT_MASK
@ -72,21 +72,20 @@ impl Request {
)
{
if matches!(k, Key::Up | Key::KP_Up | Key::Page_Up | Key::KP_Page_Up) {
if !s.back() {
e.error_bell()
if !suggestion.back() {
entry.error_bell()
}
return Propagation::Stop;
} else if matches!(
k,
Key::Down | Key::KP_Down | Key::Page_Down | Key::KP_Page_Down
) {
if !s.next() {
e.error_bell()
if !suggestion.next() {
entry.error_bell()
}
return Propagation::Stop;
}
}
s.hide();
Propagation::Proceed
}
});
@ -117,15 +116,7 @@ impl Request {
entry.connect_has_focus_notify({
let i = info.clone();
let s = suggestion.clone();
move |this| {
// toggle 'go to' button
update_secondary_icon(this, &i.borrow());
// hide suggestions on focus left this entry
if this.focus_child().is_none_or(|child| !child.has_focus()) {
s.hide()
}
}
move |e| update_secondary_icon(e, &i.borrow())
});
suggestion
@ -137,47 +128,60 @@ impl Request {
let p = profile.clone();
let r = proxy_resolver.clone();
let s = suggestion.clone();
move |this| {
move |e| {
// Allocate once
let t = this.text();
let t = e.text();
// Update actions
a.reload.set_enabled(!t.is_empty());
a.home.set_enabled(home(this).is_some());
a.home.set_enabled(home(e).is_some());
// Update icons
update_primary_icon(this, &p);
update_secondary_icon(this, &i.borrow());
refresh_proxy_resolver(this, &p, &r);
update_primary_icon(e, &p);
update_secondary_icon(e, &i.borrow());
// Show search suggestions
if this.focus_child().is_some() {
s.update(Some(50)) // @TODO optional
if e.focus_child().is_some() {
s.update(Some(50)); // @TODO optional
}
refresh_proxy_resolver(e, &p, &r)
}
})); // `suggestion` wants `signal_handler_id` to block this event on autocomplete navigation
entry.connect_activate({
let a = item_action.clone();
let s = suggestion.clone();
move |this| {
move |_| {
use gtk::prelude::ActionExt;
a.reload.activate(None);
s.hide();
a.load.activate(Some(&this.text()), true, false)
}
});
// Select entire text on first click (release)
// this behavior implemented in most web-browsers,
// to simply overwrite current request with new value
// Note:
// * Custom GestureClick is not an option here, as GTK Entry has default controller
// * This is experimental feature does not follow native GTK behavior @TODO make optional
entry.connect_has_focus_notify({
let s = suggestion.clone();
move |_| {
if s.is_visible() {
s.hide()
}
}
});
entry.connect_state_flags_changed({
let had_focus = Cell::new(false);
// Define last focus state container
let has_focus = Cell::new(false);
move |this, state| {
if !had_focus.replace(state.contains(StateFlags::FOCUS_WITHIN))
// Select entire text on first click (release)
// this behavior implemented in most web-browsers,
// to simply overwrite current request with new value
// Note:
// * Custom GestureClick is not an option here, as GTK Entry has default controller
// * This is experimental feature does not follow native GTK behavior @TODO make optional
if !has_focus.take()
&& state.contains(StateFlags::ACTIVE | StateFlags::FOCUS_WITHIN)
&& this.selection_bounds().is_none()
{
this.select_region(0, -1)
}
// Update last focus state
has_focus.replace(state.contains(StateFlags::FOCUS_WITHIN));
}
});

View file

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

View file

@ -207,7 +207,7 @@ impl Suggestion {
}
});
} else {
self.popover.popdown()
self.popover.popdown();
}
}

View file

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