Compare commits

...

105 commits

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

1
.gitignore vendored
View file

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

1806
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,9 +1,17 @@
# Yoda - Browser for [Gemini protocol](https://geminiprotocol.net) # Yoda - Browser for [Gemini protocol](https://geminiprotocol.net)
GTK 4 / Libadwaita client written in Rust ![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.
> [!IMPORTANT] > [!IMPORTANT]
> Project in development, for stable version use [release](https://github.com/YGGverse/Yoda/releases)! > Project in development, for stable version use checkpoint [releases](https://github.com/YGGverse/Yoda/releases)!
> >
![image](https://github.com/user-attachments/assets/cfbbc3fb-61d2-4afd-a21f-8e36ee329941) ![image](https://github.com/user-attachments/assets/cfbbc3fb-61d2-4afd-a21f-8e36ee329941)
@ -127,8 +135,9 @@ GTK 4 / Libadwaita client written in Rust
#### Text #### Text
* [x] `text/gemini` * [x] `text/gemini`
* [x] `text/plain` * [x] `text/markdown`
* [x] `text/nex` * [x] `text/nex`
* [x] `text/plain`
#### Images #### Images
* [x] `image/gif` * [x] `image/gif`
@ -152,13 +161,13 @@ GTK 4 / Libadwaita client written in Rust
### Requirements ### Requirements
* Cairo `1.18` * Cairo `1.18+`
* GdkPixBuf `2.42` * GdkPixBuf `2.42+`
* Glib `2.80` * Glib `2.80+`
* Gtk `4.14` * Gtk `4.14+`
* GtkSourceView `5.14` * GtkSourceView `5.14+`
* libadwaita `1.5` (Ubuntu 24.04+) * libadwaita `1.5+` (Ubuntu `24.04+`)
* libspelling `0.1` * libspelling `0.1+`
#### Debian #### Debian
@ -226,7 +235,7 @@ flatpak-builder --force-clean build\
#### Contributors #### Contributors
![wakatime](https://wakatime.com/badge/user/0b7fe6c1-b091-4c98-b930-75cfee17c7a5/project/018ebca8-4d22-4f9e-b557-186be6553d9a.svg) ![StandWithUkraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg) ![StandWithUkraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)
### Localization ### Localization

View file

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

View file

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

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -43,7 +43,7 @@ modules:
post-install: post-install:
- "install -Dm755 ./target/release/Yoda -t /app/bin" - "install -Dm755 ./target/release/Yoda -t /app/bin"
- "install -Dm644 ./data/${FLATPAK_ID}.desktop -t /app/share/applications" - "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: sources:
- type: "dir" - type: "dir"
path: "." path: "."

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -299,7 +299,7 @@ fn render(
} else if q.ends_with("/") { } else if q.ends_with("/") {
p.content.to_text_nex(&u, d) p.content.to_text_nex(&u, d)
} else if q.ends_with(".gmi") || q.ends_with(".gemini") { } else if q.ends_with(".gmi") || q.ends_with(".gemini") {
p.content.to_text_gemini(&u, d) p.content.to_text_gemini(&p.profile, &u, d)
} else { } else {
p.content.to_text_plain(d) p.content.to_text_plain(d)
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,68 @@
use gtk::{
Align, Label, TextView, TextWindowType,
glib::{Uri, timeout_add_local_once},
pango::EllipsizeMode,
prelude::{TextViewExt, WidgetExt},
};
use std::{cell::Cell, rc::Rc, time::Duration};
pub struct Gutter {
pub label: Label,
is_active: Rc<Cell<bool>>,
}
impl Gutter {
pub fn build(text_view: &TextView) -> Self {
const MARGIN_X: i32 = 8;
const MARGIN_Y: i32 = 2;
let label = Label::builder()
.css_classes(["caption", "dim-label"])
.ellipsize(EllipsizeMode::Middle)
.halign(Align::Start)
.margin_bottom(MARGIN_Y)
.margin_end(MARGIN_X)
.margin_start(MARGIN_X)
.margin_top(MARGIN_Y)
.visible(false)
.build();
text_view.set_gutter(TextWindowType::Bottom, Some(&label));
text_view
.gutter(TextWindowType::Bottom)
.unwrap()
.set_css_classes(&["view"]); // @TODO unspecified patch
Self {
is_active: Rc::new(Cell::new(false)),
label,
}
}
pub fn set_uri(&self, uri: Option<&Uri>) {
match uri {
Some(uri) => {
if !self.label.is_visible() {
if !self.is_active.replace(true) {
timeout_add_local_once(Duration::from_millis(250), {
let label = self.label.clone();
let is_active = self.is_active.clone();
let uri = uri.clone();
move || {
if is_active.replace(false) {
label.set_label(&uri.to_string());
label.set_visible(true)
}
}
});
}
} else {
self.label.set_label(&uri.to_string())
}
}
None => {
self.is_active.replace(false);
self.label.set_visible(false)
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -61,13 +61,7 @@ impl Input {
title: Option<&str>, title: Option<&str>,
size_limit: Option<usize>, size_limit: Option<usize>,
) { ) {
self.update(Some(&gtk::Box::response( self.update(Some(&gtk::Box::response(action, base, title, size_limit)));
action,
base,
title,
size_limit,
MAX_CONTENT_HEIGHT,
)));
} }
pub fn set_new_sensitive( pub fn set_new_sensitive(

View file

@ -23,7 +23,6 @@ pub trait Response {
base: Uri, base: Uri,
title: Option<&str>, title: Option<&str>,
size_limit: Option<usize>, size_limit: Option<usize>,
max_content_height: i32,
) -> Self; ) -> Self;
} }
@ -36,7 +35,6 @@ impl Response for Box {
base: Uri, base: Uri,
title: Option<&str>, title: Option<&str>,
size_limit: Option<usize>, size_limit: Option<usize>,
max_content_height: i32,
) -> Self { ) -> Self {
// Init components // Init components
let control = Rc::new(Control::build()); let control = Rc::new(Control::build());
@ -49,18 +47,12 @@ impl Response for Box {
.margin_end(MARGIN) .margin_end(MARGIN)
.margin_start(MARGIN) .margin_start(MARGIN)
.margin_top(MARGIN) .margin_top(MARGIN)
.spacing(SPACING)
.orientation(Orientation::Vertical) .orientation(Orientation::Vertical)
.spacing(SPACING)
.build(); .build();
g_box.append(&title); g_box.append(&title);
g_box.append( g_box.append(&text_view);
&gtk::ScrolledWindow::builder()
.child(&text_view)
.max_content_height(max_content_height)
.propagate_natural_height(true)
.build(),
);
g_box.append(&control.g_box); g_box.append(&control.g_box);
// Init events // Init events

View file

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

View file

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

View file

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

View file

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