mirror of
https://github.com/YGGverse/Yoda.git
synced 2026-03-31 16:45:27 +00:00
Compare commits
113 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac83ace83b | ||
|
|
38f9cca422 | ||
|
|
e92eb318b3 | ||
|
|
2891d73b37 | ||
|
|
ca9c2058ed | ||
|
|
2ef5e52079 | ||
|
|
416c0ac434 | ||
|
|
3bdabbe1b8 | ||
|
|
3358a89735 | ||
|
|
563b228e9e | ||
|
|
b6b8f96bba | ||
|
|
86ce8ceff5 | ||
|
|
13e20f0df3 | ||
|
|
84167ad745 | ||
|
|
ca29f68f69 | ||
|
|
905eee0aab | ||
|
|
6a491751b6 | ||
|
|
a1d9c080d1 | ||
|
|
12edd5a4f4 | ||
|
|
bf039dd947 | ||
|
|
0a9b2385aa | ||
|
|
f8afa8e085 | ||
|
|
bb08b7cb9a | ||
|
|
c95cb6e756 | ||
|
|
9a3cb77fe7 | ||
|
|
e4c62ca3b3 | ||
|
|
88a3e94f42 | ||
|
|
c64f2d9a9b | ||
|
|
0eebd1c85d | ||
|
|
d40eab57ec | ||
|
|
9612c988cc | ||
|
|
36568004e8 | ||
|
|
02bfc90a39 | ||
|
|
7d8bce152b | ||
|
|
36f5d29fa4 | ||
|
|
12a557eb02 | ||
|
|
722a6c8bb8 | ||
|
|
666aa5caf8 | ||
|
|
fb7e00758b | ||
|
|
0f53a899ad | ||
|
|
0cc9c69438 | ||
|
|
a8d25e695f | ||
|
|
d674edc7d0 | ||
|
|
1706f14e96 | ||
|
|
9e787468ac | ||
|
|
c6661aa656 | ||
|
|
5b8a469b5b | ||
|
|
ea2f4656a0 | ||
|
|
b8b85873ab | ||
|
|
43f348e9bb | ||
|
|
3df4a79e0a | ||
|
|
8400ed2b6a | ||
|
|
1af7d31d75 | ||
|
|
7220398492 | ||
|
|
cab1610e1f | ||
|
|
c732964494 | ||
|
|
e653675fa1 | ||
|
|
9843d49326 | ||
|
|
5675809320 | ||
|
|
e61b6c400a | ||
|
|
25e505c9fb | ||
|
|
22c50161af | ||
|
|
71f2597bf5 | ||
|
|
df419181e6 | ||
|
|
81b57f92ac | ||
|
|
266b8bfa95 | ||
|
|
c5f9690967 | ||
|
|
31346d1d63 | ||
|
|
191057cc50 | ||
|
|
fc6cce8072 | ||
|
|
6fb7e70213 | ||
|
|
3077c3b033 | ||
|
|
d512e94db1 | ||
|
|
f4416c7af9 | ||
|
|
1d6cfb88ef | ||
|
|
0357edccfe | ||
|
|
47e686dc29 | ||
|
|
fd6b9edb35 | ||
|
|
8e56daa243 | ||
|
|
4a94cd4161 | ||
|
|
ffb1474c7e | ||
|
|
812553af49 | ||
|
|
33c7cc5926 | ||
|
|
4d06c727d1 | ||
|
|
3671983372 | ||
|
|
63b5692d76 | ||
|
|
04a71e0527 | ||
|
|
acc38d39f2 | ||
|
|
aac60bfa03 | ||
|
|
9e2bf7d933 | ||
|
|
3a3bf23e18 | ||
|
|
bd5ada8960 | ||
|
|
bd8ff00a42 | ||
|
|
4341d3673a | ||
|
|
4551dc6d65 | ||
|
|
3e4b056ca6 | ||
|
|
69f2d7c779 | ||
|
|
9f4232038e | ||
|
|
d9414969d3 | ||
|
|
a1302ba9be | ||
|
|
938a2f057e | ||
|
|
a4b3a4896f | ||
|
|
28abc26f36 | ||
|
|
2df80b35a9 | ||
|
|
ac3c92e065 | ||
|
|
3d71712989 | ||
|
|
e813cdeafa | ||
|
|
4905d55e8a | ||
|
|
e123c1c1cd | ||
|
|
1a9b4802fa | ||
|
|
9cf9e8916f | ||
|
|
0b43b85905 | ||
|
|
31c9d4bab4 |
49 changed files with 5409 additions and 189 deletions
29
.github/workflows/flatpak.yml
vendored
29
.github/workflows/flatpak.yml
vendored
|
|
@ -1,29 +0,0 @@
|
||||||
name: Publish Flatpak on Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Set up Flatpak
|
|
||||||
run: |
|
|
||||||
sudo apt install flatpak
|
|
||||||
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
|
||||||
|
|
||||||
- name: Build Flatpak Bundle
|
|
||||||
run: |
|
|
||||||
flatpak-builder --force-clean build --install-deps-from=flathub --repo=repo --user io.github.yggverse.Yoda.yaml
|
|
||||||
flatpak build-bundle repo Yoda.flatpak io.github.yggverse.Yoda
|
|
||||||
|
|
||||||
- name: Upload Flatpak Bundle
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: Yoda.flatpak
|
|
||||||
path: Yoda.flatpak
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,6 +1,4 @@
|
||||||
*flatpak*
|
*flatpak*
|
||||||
!flatpak.yml
|
|
||||||
build
|
build
|
||||||
Cargo.lock
|
|
||||||
repo
|
repo
|
||||||
target
|
target
|
||||||
1806
Cargo.lock
generated
Normal file
1806
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
12
Cargo.toml
12
Cargo.toml
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "Yoda"
|
name = "Yoda"
|
||||||
version = "0.12.3"
|
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"
|
||||||
|
|
@ -31,16 +31,18 @@ version = "0.10.0"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ansi-parser = "0.9.1"
|
ansi-parser = "0.9.1"
|
||||||
anyhow = "1.0.97"
|
anyhow = "1.0.97"
|
||||||
ggemini = "0.19.0"
|
ggemini = "0.20.0"
|
||||||
ggemtext = "0.7.0"
|
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
|
||||||
|
|
|
||||||
31
README.md
31
README.md
|
|
@ -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
|

|
||||||
|
|
||||||
|
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 crates.io release!
|
> Project in development, for stable version use checkpoint [releases](https://github.com/YGGverse/Yoda/releases)!
|
||||||
>
|
>
|
||||||
|
|
||||||

|

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

|
||||||
|
|
||||||
### Localization
|
### Localization
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
205
data/io.github.yggverse.Yoda.svg
Normal file
205
data/io.github.yggverse.Yoda.svg
Normal 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 |
|
|
@ -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: "."
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(>k::gio::Menu::menu(window_action))
|
.menu_model(>k::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();
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -282,11 +282,7 @@ fn handle(
|
||||||
file_output_stream,
|
file_output_stream,
|
||||||
cancellable.clone(),
|
cancellable.clone(),
|
||||||
Priority::DEFAULT,
|
Priority::DEFAULT,
|
||||||
file_output_stream::Size {
|
file_output_stream::Size::default(),
|
||||||
chunk: 0x100000, // 1M bytes per chunk
|
|
||||||
limit: None, // unlimited
|
|
||||||
total: 0, // initial totals
|
|
||||||
},
|
|
||||||
(
|
(
|
||||||
// on chunk
|
// on chunk
|
||||||
{
|
{
|
||||||
|
|
@ -336,9 +332,8 @@ fn handle(
|
||||||
Priority::DEFAULT,
|
Priority::DEFAULT,
|
||||||
cancellable.clone(),
|
cancellable.clone(),
|
||||||
memory_input_stream::Size {
|
memory_input_stream::Size {
|
||||||
chunk: 0x400, // 1024 bytes chunk
|
|
||||||
limit: 0xfffff, // 1M limit
|
limit: 0xfffff, // 1M limit
|
||||||
total: 0, // initial totals
|
..memory_input_stream::Size::default()
|
||||||
},
|
},
|
||||||
(
|
(
|
||||||
|_, _| {}, // on chunk (maybe nothing to count yet @TODO)
|
|_, _| {}, // on chunk (maybe nothing to count yet @TODO)
|
||||||
|
|
@ -362,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
|
||||||
}
|
}
|
||||||
|
|
@ -440,9 +436,8 @@ fn handle(
|
||||||
Priority::DEFAULT,
|
Priority::DEFAULT,
|
||||||
cancellable.clone(),
|
cancellable.clone(),
|
||||||
memory_input_stream::Size {
|
memory_input_stream::Size {
|
||||||
chunk: 0x400, // 1024 bytes chunk
|
|
||||||
limit: 0xA00000, // 10M limit
|
limit: 0xA00000, // 10M limit
|
||||||
total: 0, // initial totals
|
..memory_input_stream::Size::default()
|
||||||
},
|
},
|
||||||
(
|
(
|
||||||
move |_, total| status.set_description(Some(&format!("Download: {}", total.bytes()))),
|
move |_, total| status.set_description(Some(&format!("Download: {}", total.bytes()))),
|
||||||
|
|
|
||||||
|
|
@ -193,9 +193,8 @@ impl Nex {
|
||||||
Priority::DEFAULT,
|
Priority::DEFAULT,
|
||||||
cancellable.clone(),
|
cancellable.clone(),
|
||||||
ggemini::gio::memory_input_stream::Size {
|
ggemini::gio::memory_input_stream::Size {
|
||||||
chunk: 0x400, // 1024 bytes chunk
|
|
||||||
limit: 0xA00000, // 10M limit
|
limit: 0xA00000, // 10M limit
|
||||||
total: 0, // initial totals
|
..ggemini::gio::memory_input_stream::Size::default()
|
||||||
},
|
},
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
|
@ -300,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)
|
||||||
};
|
};
|
||||||
|
|
@ -343,11 +342,7 @@ fn download(s: SocketConnection, (p, u): (Rc<Page>, Uri), c: Cancellable) {
|
||||||
file_output_stream,
|
file_output_stream,
|
||||||
c.clone(),
|
c.clone(),
|
||||||
Priority::DEFAULT,
|
Priority::DEFAULT,
|
||||||
file_output_stream::Size {
|
file_output_stream::Size::default(),
|
||||||
chunk: 0x100000, // 1M bytes per chunk
|
|
||||||
limit: None, // unlimited
|
|
||||||
total: 0, // initial totals
|
|
||||||
},
|
|
||||||
(
|
(
|
||||||
// on chunk
|
// on chunk
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(>k::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:";
|
||||||
|
|
|
||||||
610
src/app/browser/window/tab/item/page/content/text/markdown.rs
Normal file
610
src/app/browser/window/tab/item/page/content/text/markdown.rs
Normal 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(>k::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(>k::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:";
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod code;
|
||||||
|
pub mod header;
|
||||||
|
pub mod link;
|
||||||
|
pub mod list;
|
||||||
|
pub mod quote;
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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 ")
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let first = cap.first().unwrap();
|
||||||
|
assert_eq!(&first[0], "## Header ");
|
||||||
|
assert_eq!(&first["level"], "##");
|
||||||
|
assert_eq!(&first["title"], "Header ");
|
||||||
|
}
|
||||||
|
|
@ -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 ";
|
||||||
|
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 "
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_HR)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter("Some line\n---\nSome another-line with ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(&cap.first().unwrap()["hr"], "---");
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ";
|
||||||
|
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 ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_PRE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(r"Some `pre 1` and `pre 2` with ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(&cap.first().unwrap()["text"], "pre 1");
|
||||||
|
assert_eq!(&cap.get(1).unwrap()["text"], "pre 2");
|
||||||
|
}
|
||||||
|
|
@ -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 \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 "
|
||||||
|
);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
@ -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"[](https://image2.com) [](https://image4.com)"
|
||||||
|
).collect();
|
||||||
|
|
||||||
|
let first = cap.first().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
&first[0],
|
||||||
|
"[](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],
|
||||||
|
"[](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" ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let first = cap.first().unwrap();
|
||||||
|
assert_eq!(&first[0], "");
|
||||||
|
assert_eq!(&first["alt"], "image1");
|
||||||
|
assert_eq!(&first["url"], "https://image1.com");
|
||||||
|
|
||||||
|
let second = cap.get(1).unwrap();
|
||||||
|
assert_eq!(&second[0], "");
|
||||||
|
assert_eq!(&second["alt"], "image2");
|
||||||
|
assert_eq!(&second["url"], "https://image2.com");
|
||||||
|
}
|
||||||
|
|
@ -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 ";
|
||||||
|
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 "
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_STRIKE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(r"Some ~~strike 1~~ and ~~strike 2~~ with ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(&cap.first().unwrap()["text"], "strike 1");
|
||||||
|
assert_eq!(&cap.get(1).unwrap()["text"], "strike 2");
|
||||||
|
}
|
||||||
|
|
@ -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 ";
|
||||||
|
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 "
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_regex() {
|
||||||
|
let cap: Vec<_> = Regex::new(REGEX_UNDERLINE)
|
||||||
|
.unwrap()
|
||||||
|
.captures_iter(r"Some _underline 1_ and _underline 2_ with ")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(&cap.first().unwrap()["text"], "underline 1");
|
||||||
|
assert_eq!(&cap.get(1).unwrap()["text"], "underline 2");
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,9 @@ impl Input {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_new_titan(&self, on_send: impl Fn(titan::Header, Bytes, Box<dyn Fn()>) + 'static) {
|
pub fn set_new_titan(&self, on_send: impl Fn(titan::Header, Bytes, Box<dyn Fn()>) + 'static) {
|
||||||
self.update(Some(>k::Box::titan(on_send)));
|
self.update(Some(>k::Box::titan(MAX_CONTENT_HEIGHT, on_send)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @TODO optional, maybe relative to the current window height in %
|
||||||
|
const MAX_CONTENT_HEIGHT: i32 = 280;
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -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| {
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,17 @@ use tab::Tab;
|
||||||
use text::Text;
|
use text::Text;
|
||||||
|
|
||||||
pub trait Titan {
|
pub trait Titan {
|
||||||
fn titan(callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static) -> Self;
|
fn titan(
|
||||||
|
max_content_height: i32,
|
||||||
|
callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static,
|
||||||
|
) -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Titan for gtk::Box {
|
impl Titan for gtk::Box {
|
||||||
fn titan(callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static) -> Self {
|
fn titan(
|
||||||
|
max_content_height: i32,
|
||||||
|
callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static,
|
||||||
|
) -> Self {
|
||||||
use gtk::{Label, glib::uuid_string_random, prelude::ButtonExt};
|
use gtk::{Label, glib::uuid_string_random, prelude::ButtonExt};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
|
@ -35,7 +41,15 @@ impl Titan for gtk::Box {
|
||||||
.show_border(false)
|
.show_border(false)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
notebook.append_page(&text.text_view, Some(&Label::tab("Text")));
|
notebook.append_page(
|
||||||
|
>k::ScrolledWindow::builder()
|
||||||
|
.child(&text.text_view)
|
||||||
|
.max_content_height(max_content_height)
|
||||||
|
.propagate_natural_height(true)
|
||||||
|
.build(),
|
||||||
|
Some(&Label::tab("Text")),
|
||||||
|
);
|
||||||
|
|
||||||
notebook.append_page(&file.button, Some(&Label::tab("File")));
|
notebook.append_page(&file.button, Some(&Label::tab("File")));
|
||||||
|
|
||||||
notebook.connect_switch_page({
|
notebook.connect_switch_page({
|
||||||
|
|
|
||||||
|
|
@ -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| {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue