diff --git a/Cargo.lock b/Cargo.lock
index 099b9cf..462b68d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -321,6 +321,15 @@ dependencies = [
"wyz",
]
+[[package]]
+name = "block2"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
+dependencies = [
+ "objc2",
+]
+
[[package]]
name = "built"
version = "0.8.0"
@@ -377,6 +386,31 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+[[package]]
+name = "calloop"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
+dependencies = [
+ "bitflags 2.11.1",
+ "polling",
+ "rustix",
+ "slab",
+ "tracing",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
+dependencies = [
+ "calloop",
+ "rustix",
+ "wayland-backend",
+ "wayland-client",
+]
+
[[package]]
name = "cast"
version = "0.3.0"
@@ -499,6 +533,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+[[package]]
+name = "clipboard-win"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+dependencies = [
+ "error-code",
+]
+
[[package]]
name = "compact_str"
version = "0.9.0"
@@ -513,6 +556,15 @@ dependencies = [
"static_assertions",
]
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "console-api"
version = "0.9.0"
@@ -562,6 +614,20 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "copypasta"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a"
+dependencies = [
+ "clipboard-win",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "smithay-clipboard",
+ "x11-clipboard",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -747,6 +813,12 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "cursor-icon"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+
[[package]]
name = "darling"
version = "0.23.0"
@@ -857,6 +929,12 @@ dependencies = [
"litrs",
]
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
[[package]]
name = "dwrote"
version = "0.11.5"
@@ -921,6 +999,12 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "error-code"
+version = "3.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
+
[[package]]
name = "exr"
version = "1.74.0"
@@ -1132,6 +1216,16 @@ dependencies = [
"slab",
]
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix",
+ "windows-link 0.2.1",
+]
+
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1233,6 +1327,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
[[package]]
name = "http"
version = "1.4.0"
@@ -1464,6 +1564,25 @@ dependencies = [
"syn",
]
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
[[package]]
name = "itertools"
version = "0.13.0"
@@ -1956,6 +2075,105 @@ dependencies = [
"libc",
]
+[[package]]
+name = "objc-sys"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
+
+[[package]]
+name = "objc2"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
+dependencies = [
+ "objc-sys",
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "libc",
+ "objc2",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-foundation",
+ "objc2-quartz-core",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
+dependencies = [
+ "block2",
+ "objc2",
+ "objc2-foundation",
+ "objc2-metal",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "objc2",
+ "objc2-foundation",
+ "objc2-metal",
+]
+
[[package]]
name = "object"
version = "0.37.3"
@@ -1977,6 +2195,17 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+[[package]]
+name = "open"
+version = "5.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
+dependencies = [
+ "is-wsl",
+ "libc",
+ "pathdiff",
+]
+
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2067,6 +2296,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
+[[package]]
+name = "pathdiff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
[[package]]
name = "pathfinder_geometry"
version = "0.5.1"
@@ -2164,6 +2399,20 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -2287,6 +2536,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quote"
version = "1.0.45"
@@ -2628,6 +2886,12 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2756,6 +3020,44 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
+dependencies = [
+ "bitflags 2.11.1",
+ "calloop",
+ "calloop-wayland-source",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix",
+ "thiserror",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-experimental",
+ "wayland-protocols-misc",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-clipboard"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
+dependencies = [
+ "libc",
+ "smithay-client-toolkit",
+ "wayland-backend",
+]
+
[[package]]
name = "socket2"
version = "0.6.3"
@@ -2842,6 +3144,7 @@ name = "tdf-viewer"
version = "0.5.0"
dependencies = [
"console-subscriber",
+ "copypasta",
"cpuprofiler",
"criterion",
"crossterm",
@@ -2858,6 +3161,7 @@ dependencies = [
"mupdf",
"nix",
"notify",
+ "open",
"ratatui",
"ratatui-image",
"rayon",
@@ -3054,6 +3358,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
+ "log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -3238,6 +3543,128 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wayland-backend"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
+dependencies = [
+ "bitflags 2.11.1",
+ "rustix",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-csd-frame"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
+dependencies = [
+ "bitflags 2.11.1",
+ "cursor-icon",
+ "wayland-backend",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
+dependencies = [
+ "rustix",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-experimental"
+version = "20250721.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-misc"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
+dependencies = [
+ "bitflags 2.11.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
[[package]]
name = "web-sys"
version = "0.3.95"
@@ -3562,6 +3989,39 @@ dependencies = [
"tap",
]
+[[package]]
+name = "x11-clipboard"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3"
+dependencies = [
+ "libc",
+ "x11rb",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "gethostname",
+ "rustix",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
+[[package]]
+name = "xcursor"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
+
[[package]]
name = "xflags"
version = "0.4.0-pre.2"
@@ -3577,6 +4037,12 @@ version = "0.4.0-pre.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a6d9b56f406f5754a3808524166b6e6bdfe219c0526e490cfc39ecc0582a4e6"
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
[[package]]
name = "y4m"
version = "0.8.0"
diff --git a/Cargo.toml b/Cargo.toml
index fe612fc..1593a2d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -53,6 +53,8 @@ flexi_logger = "0.31"
# for tracing with tokio-console
console-subscriber = { version = "0.5.0", optional = true }
+copypasta = "0.10.2"
+open = "5.3.5"
[patch.crates-io]
pathfinder_simd = { git = "https://github.com/itsjunetime/pathfinder.git", rev = "814671e162a1829e074521446317b915311d3d4d" }
diff --git a/src/converter.rs b/src/converter.rs
index 177aa36..9197abe 100644
--- a/src/converter.rs
+++ b/src/converter.rs
@@ -17,7 +17,7 @@ use ratatui_image::{
use rayon::iter::ParallelIterator as _;
use crate::{
- renderer::{PageInfo, RenderError, fill_default},
+ renderer::{HighlightRect, Link, PageInfo, RenderError, fill_default},
skip::InterleavedAroundWithMax
};
@@ -57,7 +57,8 @@ impl ConvertedImage {
pub struct ConvertedPage {
pub page: ConvertedImage,
pub num: usize,
- pub num_results: usize
+ pub num_results: usize,
+ pub links: Vec
}
pub enum ConverterMsg {
@@ -66,6 +67,27 @@ pub enum ConverterMsg {
AddImg(PageInfo)
}
+struct RectangleFilter;
+impl RectangleFilter {
+ const BW: u32 = 2;
+
+ #[inline]
+ fn filled(x: u32, y: u32, quad: &HighlightRect) -> bool {
+ x > quad.ul_x && x < quad.lr_x && y > quad.ul_y && y < quad.lr_y
+ }
+
+ #[inline]
+ fn outline(x: u32, y: u32, quad: &HighlightRect) -> bool {
+ let within_x = quad.ul_x <= x && x <= quad.lr_x;
+ let within_y = quad.ul_y <= y && y <= quad.lr_y;
+
+ (y < quad.ul_y && quad.ul_y - RectangleFilter::BW < y) && within_x // top
+ || (y > quad.lr_y && quad.lr_y + RectangleFilter::BW > y) && within_x // bottom
+ || (x < quad.ul_x && quad.ul_x - RectangleFilter::BW < x) && within_y // left
+ || (x > quad.lr_x && quad.lr_x + RectangleFilter::BW > x) && within_y // right
+ }
+}
+
pub async fn run_conversion_loop(
sender: Sender>,
receiver: Receiver,
@@ -128,12 +150,23 @@ pub async fn run_conversion_loop(
for quad in &*page_info.result_rects {
dyn_img
.par_enumerate_pixels_mut()
- .filter(|(x, y, _)| {
- *x > quad.ul_x && *x < quad.lr_x && *y > quad.ul_y && *y < quad.lr_y
- })
+ .filter(|(x, y, _)| RectangleFilter::filled(*x, *y, quad))
.for_each(|(_, _, px)| px.0[2] = px.0[2].saturating_sub(u8::MAX / 2));
}
+ for Link { rects, is_selected, .. } in &*page_info.links {
+ let filter = match is_selected {
+ true => RectangleFilter::filled,
+ false => RectangleFilter::outline
+ };
+ for rect in rects {
+ dyn_img
+ .par_enumerate_pixels_mut()
+ .filter(|(x, y, _)| filter(*x, *y, rect))
+ .for_each(|(_, _, px)| px.0[0] = px.0[0].saturating_sub(u8::MAX / 2));
+ }
+ }
+
let img_area = Rect {
width: page_info.img_data.cell_w,
height: page_info.img_data.cell_h,
@@ -195,7 +228,8 @@ pub async fn run_conversion_loop(
Ok(Some(ConvertedPage {
page: txt_img,
num: page_info.page_num,
- num_results: page_info.result_rects.len()
+ num_results: page_info.result_rects.len(),
+ links: page_info.links.clone()
}))
}
diff --git a/src/main.rs b/src/main.rs
index 454fef1..1d0a87f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -417,6 +417,7 @@ async fn enter_redraw_loop(
InputAction::SwitchRenderZoom(f_or_f) => {
to_renderer.send(RenderNotif::SwitchFitOrFill(f_or_f)).unwrap();
}
+ InputAction::HighlightLink(maybe_link) => to_renderer.send(RenderNotif::HighlightLink(maybe_link))?
}
}
},
@@ -440,8 +441,8 @@ async fn enter_redraw_loop(
}
Some(img_res) = from_converter.next() => {
match img_res {
- Ok(ConvertedPage { page, num, num_results }) => {
- tui.page_ready(page, num, num_results);
+ Ok(ConvertedPage { page, num, num_results, links }) => {
+ tui.page_ready(page, num, num_results, links);
if num == tui.page {
needs_redraw = true;
}
diff --git a/src/renderer.rs b/src/renderer.rs
index 9b93c56..b440abe 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -21,7 +21,8 @@ pub enum RenderNotif {
SwitchFitOrFill(FitOrFill),
Reload,
Invert,
- Rotate
+ Rotate,
+ HighlightLink(Option)
}
#[derive(Debug)]
@@ -50,7 +51,8 @@ pub enum RotateDirection {
pub struct PageInfo {
pub img_data: ImageData,
pub page_num: usize,
- pub result_rects: Vec
+ pub result_rects: Vec,
+ pub links: Vec,
}
#[derive(Clone)]
@@ -63,7 +65,22 @@ pub struct ImageData {
#[derive(Default)]
struct PrevRender {
successful: bool,
- num_search_found: Option
+ num_search_found: Option,
+ highlighted_link: Option
+}
+
+#[derive(Clone, Debug)]
+pub enum LinkDestination {
+ URI(String),
+ Page(u32),
+}
+
+#[derive(Clone, Debug)]
+pub struct Link {
+ // Rectangles to highlight, same link can have multiple rectangles if split across lines
+ pub rects: Vec,
+ pub is_selected: bool,
+ pub dest: LinkDestination
}
pub const MUPDF_BLACK: i32 = 0;
@@ -178,6 +195,9 @@ pub fn start_rendering(
fill_default::(&mut rendered, n_pages.get());
let mut start_point = 0;
+ // todo there must be a better way to do this
+ let mut link_highlight_changed = false;
+
// This is kinda a weird way of doing this, but if we get a notification that the area
// changed, we want to start re-rending all of the pages, but we don't want to reload the
// document. If there was a mechanism to say 'start this for-loop over' then I would do
@@ -223,6 +243,8 @@ pub fn start_rendering(
continue 'render_pages;
},
RenderNotif::JumpToPage(page) => {
+ // clear old highlight
+ rendered[start_point].highlighted_link = None;
start_point = page;
continue 'render_pages;
}
@@ -267,6 +289,11 @@ pub fn start_rendering(
}
continue 'render_pages;
}
+ RenderNotif::HighlightLink(maybe_link) => {
+ link_highlight_changed = true;
+ rendered[start_point].highlighted_link = maybe_link;
+ continue 'render_pages;
+ }
}
}};
}
@@ -307,10 +334,14 @@ pub fn start_rendering(
// 1. It failed to render last time (we want to retry)
// 2. The `contained_term` is set to Unknown, meaning that we need to at least
// check if it contains the current term to see if it needs a re-render
- if rendered.successful && rendered.num_search_found.is_some() {
+ if rendered.successful && rendered.num_search_found.is_some()
+ && !(page_num == start_point && link_highlight_changed){
continue;
}
+ // consume this since it is now stale state
+ link_highlight_changed = false;
+
// We know this is in range 'cause we're iterating over it but we still just want
// to be safe
let page = match doc.load_page(page_num as i32) {
@@ -357,7 +388,8 @@ pub fn start_rendering(
cell_h: (ctx.surface_h / f32::from(col_h)) as u16
},
page_num,
- result_rects: ctx.result_rects
+ result_rects: ctx.result_rects,
+ links: ctx.links,
})))?;
}
// And if we got an error, then obviously we need to propagate that
@@ -473,7 +505,8 @@ struct RenderedContext {
pixmap: Pixmap,
surface_w: f32,
surface_h: f32,
- result_rects: Vec
+ result_rects: Vec,
+ links: Vec
}
#[expect(clippy::too_many_arguments)]
@@ -555,11 +588,16 @@ fn render_single_page_to_ctx(
})
.collect::>();
+ log::debug!("prev_highlight: {:?}", prev_render.highlighted_link);
+ let links = extract_page_links(page, scale_factor, prev_render.highlighted_link)?;
+ log::debug!("links: {:?}", links);
+
Ok(RenderedContext {
pixmap,
surface_w,
surface_h,
- result_rects
+ result_rects,
+ links
})
}
@@ -604,6 +642,60 @@ fn count_search_results(page: &Page, search_term: &str) -> Result,
+) -> Result, mupdf::error::Error> {
+ let link_groups = page.links()?
+ .fold(Vec::>::new(), |mut acc, link| {
+ if let Some(group) = acc.last_mut() {
+ let prev_link = group.last().unwrap();
+ // todo fix
+ // the commented out lines ignores page margins
+ // but something like this is needed in case there are two separate links with the same uri
+ // across lines
+ let is_same = prev_link.uri == link.uri
+ && prev_link.dest == link.dest
+ && prev_link.bounds.y1 == link.bounds.y0;
+ // && prev_link.bounds.x1 == bounds.x1
+ // && link.bounds.x0 == bounds.x0;
+ if is_same {
+ group.push(link);
+ } else {
+ acc.push(vec![link]);
+ }
+ } else {
+ acc.push(vec![link]);
+ }
+ acc
+ });
+
+ Ok(link_groups
+ .iter()
+ .enumerate()
+ .map(|(c, link_group)| Link {
+ rects: link_group
+ .iter()
+ .map(|link| {
+ let rect = link.bounds;
+ HighlightRect {
+ ul_x: (rect.x0 * scale_factor) as u32,
+ ul_y: (rect.y0 * scale_factor) as u32,
+ lr_x: (rect.x1 * scale_factor) as u32,
+ lr_y: (rect.y1 * scale_factor) as u32,
+ }
+ })
+ .collect(),
+ dest: link_group[0].dest.map_or(
+ LinkDestination::URI(link_group[0].uri.clone()),
+ |d| LinkDestination::Page(d.loc.page_number)
+ ),
+ is_selected: highlighted_link.is_some_and(|idx| idx == c),
+ })
+ .collect())
+}
+
struct PopOnNext<'a> {
inner: &'a mut VecDeque
}
diff --git a/src/tui.rs b/src/tui.rs
index 707823d..bb3f0a5 100644
--- a/src/tui.rs
+++ b/src/tui.rs
@@ -1,5 +1,4 @@
-use std::{borrow::Cow, io::stdout, num::NonZeroUsize};
-
+use std::{io::stdout, num::NonZeroUsize};
use crossterm::{
event::{Event, KeyCode, KeyModifiers, MouseEventKind},
execute,
@@ -24,7 +23,7 @@ use crate::{
FitOrFill,
converter::{ConvertedImage, MaybeTransferred},
kitty::{KittyDisplay, KittyReadyToDisplay},
- renderer::{RenderError, fill_default},
+ renderer::{Link, LinkDestination, RenderError, fill_default},
skip::Skip
};
@@ -174,7 +173,9 @@ pub struct RenderedInfo {
// we haven't checked this page yet
// Also this isn't the most efficient representation of this value, but it's accurate, so like
// whatever I guess
- num_results: Option
+ num_results: Option,
+ // Links on the page
+ links: Vec
}
#[derive(PartialEq)]
@@ -597,7 +598,13 @@ impl Tui {
self.page = self.page.min(n_pages - 1);
}
- pub fn page_ready(&mut self, img: ConvertedImage, page_num: usize, num_results: usize) {
+ pub fn page_ready(
+ &mut self,
+ img: ConvertedImage,
+ page_num: usize,
+ num_results: usize,
+ links: Vec
+ ) {
// If this new image woulda fit within the available space on the last render AND it's
// within the range where it might've been rendered with the last shown pages, then reset
// the last rect marker so that all images are forced to redraw on next render and this one
@@ -619,7 +626,8 @@ impl Tui {
// number of pages, so the vec will already be cleared
self.rendered[page_num] = RenderedInfo {
img: Some(img),
- num_results: Some(num_results)
+ num_results: Some(num_results),
+ links
};
}
@@ -678,6 +686,9 @@ impl Tui {
} else {
String::new()
};
+
+ let has_links = rendered.get(page_num).is_some_and(|r| !r.links.is_empty());
+
let bottom_layout = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(rendered_str.len() as u16)
@@ -687,35 +698,58 @@ impl Tui {
let rendered_span = Span::styled(&rendered_str, Style::new().fg(Color::Cyan));
frame.render_widget(rendered_span, bottom_layout[1]);
- let (msg_str, color): (Cow<'_, str>, _) = match bottom_msg {
- BottomMessage::Help => ("?: Show help page".into(), Color::Blue),
- BottomMessage::Error(e) => (e.as_str().into(), Color::Red),
- BottomMessage::Input(input_state) => (
- match input_state {
+ // Handle different bottom messages and render with appropriate colors
+ match bottom_msg {
+ BottomMessage::Help => {
+ // Create spans with different colors for help message and links info
+ let mut spans = vec![Span::styled(
+ "?: Show help page",
+ Style::new().fg(Color::Blue)
+ )];
+ if has_links {
+ spans.push(Span::styled(" | ", Style::new().fg(Color::DarkGray)));
+ spans.push(Span::styled(
+ match rendered[page_num].links.iter().find(|l| l.is_selected) {
+ None => format!("{} links", rendered[page_num].links.len()),
+ Some(l) => match &l.dest {
+ LinkDestination::URI(uri) => format!("URI: {uri}"),
+ LinkDestination::Page(num) => format!("To page {num}"),
+ }
+ },
+ Style::new().fg(Color::Yellow)
+ ));
+ }
+ let help_line = ratatui::text::Line::from(spans);
+ frame.render_widget(Paragraph::new(help_line), bottom_layout[0]);
+ }
+ BottomMessage::Error(e) => {
+ let span = Span::styled(e.as_str(), Style::new().fg(Color::Red));
+ frame.render_widget(span, bottom_layout[0]);
+ }
+ BottomMessage::Input(input_state) => {
+ let msg_str = match input_state {
InputCommand::GoToPage(page) => format!("Go to: {page}"),
InputCommand::Search(s) => format!("Search: {s}")
- }
- .into(),
- Color::Blue
- ),
+ };
+ let span = Span::styled(msg_str, Style::new().fg(Color::Blue));
+ frame.render_widget(span, bottom_layout[0]);
+ }
BottomMessage::SearchResults(term) => {
let num_found = rendered.iter().filter_map(|r| r.num_results).sum::();
let num_searched =
rendered.iter().filter(|r| r.num_results.is_some()).count() * 100;
- (
- format!(
- "Results for '{term}': {num_found} (searched: {}%)",
- num_searched / rendered.len()
- )
- .into(),
- Color::Blue
- )
+ let msg_str = format!(
+ "Results for '{term}': {num_found} (searched: {}%)",
+ num_searched / rendered.len()
+ );
+ let span = Span::styled(msg_str, Style::new().fg(Color::Blue));
+ frame.render_widget(span, bottom_layout[0]);
}
- BottomMessage::Reloaded => ("Document was reloaded!".into(), Color::Blue)
- };
-
- let span = Span::styled(msg_str, Style::new().fg(color));
- frame.render_widget(span, bottom_layout[0]);
+ BottomMessage::Reloaded => {
+ let span = Span::styled("Document was reloaded!", Style::new().fg(Color::Blue));
+ frame.render_widget(span, bottom_layout[0]);
+ }
+ }
}
pub fn handle_event(&mut self, ev: &Event) -> Option {
@@ -727,6 +761,8 @@ impl Tui {
}
let can_zoom = self.is_kitty && self.zoom.is_some();
+ let current_page_links = &self.rendered.get(self.page)?.links;
+ let highlighted_link = current_page_links.iter().position(|l| l.is_selected);
match ev {
Event::Key(key) => {
@@ -858,6 +894,15 @@ impl Tui {
'0' if can_zoom => self.update_zoom(Zoom::pan_left),
'$' if can_zoom => self.update_zoom(Zoom::pan_right),
'r' => Some(InputAction::Rotate),
+ 'c' => highlighted_link.inspect(|&l| {
+ let link = ¤t_page_links[l];
+ // Copy to clipboard using copypasta
+ use copypasta::{ClipboardContext, ClipboardProvider};
+ if let LinkDestination::URI(ref uri) = link.dest
+ && let Ok(mut ctx) = ClipboardContext::new() {
+ let _ = ctx.set_contents(uri.clone());
+ }
+ }).and(None),
_ => None
},
KeyCode::Backspace
@@ -873,8 +918,13 @@ impl Tui {
KeyCode::Left => self.change_page(PageChange::Prev, ChangeAmount::Single),
KeyCode::Up | KeyCode::PageUp =>
self.change_page(PageChange::Prev, ChangeAmount::WholeScreen),
- KeyCode::Esc => match (self.showing_help_msg, &self.bottom_msg) {
- (false, BottomMessage::Help) => Some(InputAction::QuitApp),
+ KeyCode::Esc => match (
+ self.showing_help_msg,
+ highlighted_link,
+ &self.bottom_msg
+ ) {
+ (false, None, BottomMessage::Help) => Some(InputAction::QuitApp),
+ (false, Some(_), _) => Some(InputAction::HighlightLink(None)),
_ => {
// When we hit escape, we just want to pop off the current message and
// show the underlying one.
@@ -885,9 +935,26 @@ impl Tui {
KeyCode::Enter => {
let mut default = BottomMessage::default();
std::mem::swap(&mut self.bottom_msg, &mut default);
- let BottomMessage::Input(ref cmd) = default else {
- std::mem::swap(&mut self.bottom_msg, &mut default);
- return None;
+ // I've tried to hook LinkDestination::Page into cmd so that the logic of GoToPage below can be reused
+ // But there is probably a better way to do this, maybe wrap it into a function
+ let cmd: &InputCommand = match default {
+ BottomMessage::Input(ref cmd) => cmd,
+ _ => {
+ std::mem::swap(&mut self.bottom_msg, &mut default);
+ match highlighted_link {
+ Some(l) => {
+ let link = ¤t_page_links[l];
+ match &link.dest {
+ LinkDestination::Page(page) => &InputCommand::GoToPage(*page as usize),
+ LinkDestination::URI(uri) => {
+ let _ = open::that(uri);
+ return None;
+ }
+ }
+ },
+ None => return None
+ }
+ }
};
match cmd {
@@ -936,6 +1003,15 @@ impl Tui {
Some(InputAction::Search(term))
}
}
+ },
+ key @ (KeyCode::Tab | KeyCode::BackTab) => {
+ let n_links = current_page_links.len();
+ match (highlighted_link, key) {
+ (None, _) => Some(InputAction::HighlightLink(Some(0))), // No link is highlighted yet, highlight the first one
+ (Some(l), KeyCode::Tab) => Some(InputAction::HighlightLink(Some((l + 1) % n_links))),
+ (Some(l), KeyCode::BackTab) => Some(InputAction::HighlightLink(Some((l + n_links - 1) % n_links))),
+ _ => unreachable!() // key can't be anything else
+ }
}
_ => None
}
@@ -1138,7 +1214,8 @@ pub enum InputAction {
Invert,
Rotate,
Fullscreen,
- SwitchRenderZoom(crate::FitOrFill)
+ SwitchRenderZoom(crate::FitOrFill),
+ HighlightLink(Option) // None => remove highlight
}
#[derive(Copy, Clone)]