From fdc066d757eb004e23a814f3d9062c88258c760a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tun=C3=A7=20Ba=C5=9Far=20K=C3=B6se?= Date: Sat, 6 Jun 2026 22:36:06 +0300 Subject: [PATCH] Links with highlight --- Cargo.lock | 466 +++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + src/converter.rs | 46 ++++- src/main.rs | 5 +- src/renderer.rs | 106 ++++++++++- src/tui.rs | 145 +++++++++++---- 6 files changed, 721 insertions(+), 49 deletions(-) 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)]