diff --git a/Cargo.lock b/Cargo.lock
index 5642630..3b98aff 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -308,6 +308,15 @@ dependencies = [
"generic-array",
]
+[[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.7.7"
@@ -344,6 +353,32 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+[[package]]
+name = "calloop"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
+dependencies = [
+ "bitflags 2.9.2",
+ "log",
+ "polling",
+ "rustix 0.38.44",
+ "slab",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
+dependencies = [
+ "calloop",
+ "rustix 0.38.44",
+ "wayland-backend",
+ "wayland-client",
+]
+
[[package]]
name = "cast"
version = "0.3.0"
@@ -476,6 +511,15 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+[[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"
@@ -490,6 +534,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.8.1"
@@ -538,6 +591,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"
@@ -750,6 +817,12 @@ dependencies = [
"phf",
]
+[[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.20.11"
@@ -870,6 +943,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.3"
@@ -934,6 +1013,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 = "euclid"
version = "0.22.11"
@@ -1158,6 +1243,16 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "gethostname"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818"
+dependencies = [
+ "libc",
+ "windows-targets 0.48.5",
+]
+
[[package]]
name = "getrandom"
version = "0.2.16"
@@ -1258,6 +1353,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 = "hex"
version = "0.4.3"
@@ -1512,6 +1613,25 @@ dependencies = [
"libc",
]
+[[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.12.1"
@@ -2035,6 +2155,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.9.2",
+ "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.9.2",
+ "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.9.2",
+ "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.9.2",
+ "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.9.2",
+ "block2",
+ "objc2",
+ "objc2-foundation",
+ "objc2-metal",
+]
+
[[package]]
name = "object"
version = "0.36.7"
@@ -2056,6 +2275,17 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+[[package]]
+name = "open"
+version = "5.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95"
+dependencies = [
+ "is-wsl",
+ "libc",
+ "pathdiff",
+]
+
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2100,6 +2330,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+[[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"
@@ -2300,6 +2536,20 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "polling"
+version = "3.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix 1.0.8",
+ "windows-sys 0.60.2",
+]
+
[[package]]
name = "powerfmt"
version = "0.2.0"
@@ -2400,6 +2650,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+[[package]]
+name = "quick-xml"
+version = "0.37.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quote"
version = "1.0.40"
@@ -2746,6 +3005,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"
@@ -2888,6 +3153,42 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
+dependencies = [
+ "bitflags 2.9.2",
+ "calloop",
+ "calloop-wayland-source",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 0.38.44",
+ "thiserror 1.0.69",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-clipboard"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc8216eec463674a0e90f29e0ae41a4db573ec5b56b1c6c1c71615d249b6d846"
+dependencies = [
+ "libc",
+ "smithay-client-toolkit",
+ "wayland-backend",
+]
+
[[package]]
name = "socket2"
version = "0.5.10"
@@ -3002,6 +3303,7 @@ name = "tdf-viewer"
version = "0.4.2"
dependencies = [
"console-subscriber",
+ "copypasta",
"cpuprofiler",
"criterion",
"crossterm",
@@ -3017,6 +3319,7 @@ dependencies = [
"mupdf",
"nix 0.30.1",
"notify",
+ "open",
"ratatui",
"ratatui-image",
"rayon",
@@ -3578,6 +3881,102 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wayland-backend"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.0.8",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
+dependencies = [
+ "bitflags 2.9.2",
+ "rustix 1.0.8",
+ "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.9.2",
+ "cursor-icon",
+ "wayland-backend",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29"
+dependencies = [
+ "rustix 1.0.8",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
+dependencies = [
+ "bitflags 2.9.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
+dependencies = [
+ "bitflags 2.9.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
[[package]]
name = "web-sys"
version = "0.3.77"
@@ -3841,6 +4240,21 @@ dependencies = [
"windows-targets 0.53.3",
]
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -3874,6 +4288,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.0",
]
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -3886,6 +4306,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -3898,6 +4324,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -3922,6 +4354,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -3934,6 +4372,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -3946,6 +4390,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -3958,6 +4408,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -3997,6 +4453,39 @@ dependencies = [
"bitflags 2.9.2",
]
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12"
+dependencies = [
+ "gethostname",
+ "rustix 0.38.44",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
+
+[[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"
@@ -4012,6 +4501,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 = "yeslogic-fontconfig-sys"
version = "6.0.0"
diff --git a/Cargo.toml b/Cargo.toml
index 12636f8..636d9b1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -40,6 +40,8 @@ mimalloc = "0.1.43"
nix = { version = "0.30.0", features = ["signal"] }
mupdf = { version = "0.5.0", default-features = false, features = ["svg", "system-fonts", "img"] }
rayon = { version = "*", default-features = false }
+copypasta = "0.10.2"
+open = "5.3.2"
# kittage = { path = "../kittage/", features = ["crossterm-tokio", "image-crate", "log"] }
kittage = { git = "https://github.com/itsjunetime/kittage.git", features = ["crossterm-tokio", "image-crate", "log"] }
memmap2 = "*"
diff --git a/src/converter.rs b/src/converter.rs
index 2170824..69e368c 100644
--- a/src/converter.rs
+++ b/src/converter.rs
@@ -16,7 +16,7 @@ use ratatui_image::{
use rayon::iter::ParallelIterator;
use crate::{
- renderer::{PageInfo, RenderError, fill_default},
+ renderer::{Link, PageInfo, RenderError, fill_default},
skip::InterleavedAroundWithMax
};
@@ -54,7 +54,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 {
@@ -174,7 +175,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 c00cfe4..7d3ae4b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -380,8 +380,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 f3a0fb1..ad40628 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -41,7 +41,8 @@ pub enum RenderInfo {
pub struct PageInfo {
pub img_data: ImageData,
pub page_num: usize,
- pub result_rects: Vec
+ pub result_rects: Vec,
+ pub links: Vec
}
#[derive(Clone)]
@@ -327,7 +328,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
@@ -443,7 +445,8 @@ struct RenderedContext {
pixmap: Pixmap,
surface_w: f32,
surface_h: f32,
- result_rects: Vec
+ result_rects: Vec,
+ links: Vec
}
#[expect(clippy::too_many_arguments)]
@@ -512,11 +515,14 @@ fn render_single_page_to_ctx(
})
.collect::>();
+ let links = extract_page_links(page)?;
+
Ok(RenderedContext {
pixmap,
surface_w,
surface_h,
- result_rects
+ result_rects,
+ links
})
}
@@ -528,6 +534,12 @@ pub struct HighlightRect {
pub lr_y: u32
}
+#[derive(Clone, Debug)]
+pub struct Link {
+ pub uri: String,
+ pub target_page: Option
+}
+
#[inline]
fn search_page(
page: &Page,
@@ -563,6 +575,30 @@ fn count_search_results(page: &Page, search_term: &str) -> Result Result, mupdf::error::Error> {
+ let links = page.links()?;
+ let mut unique_links = Vec::new();
+ let mut seen_uris = std::collections::HashSet::new();
+
+ for link in links {
+ // Only include HTTP/HTTPS URLs, skip internal links and empty URIs
+ if link.uri.starts_with("http") {
+ let uri = link.uri.clone();
+
+ // Only add if we haven't seen this URI before
+ if !seen_uris.contains(&uri) {
+ seen_uris.insert(uri.clone());
+
+ let target_page = Some(link.page);
+
+ unique_links.push(Link { uri, target_page });
+ }
+ }
+ }
+
+ Ok(unique_links)
+}
+
struct PopOnNext<'a> {
inner: &'a mut VecDeque
}
diff --git a/src/tui.rs b/src/tui.rs
index b08f950..8a32cc8 100644
--- a/src/tui.rs
+++ b/src/tui.rs
@@ -1,4 +1,4 @@
-use std::{borrow::Cow, io::stdout, num::NonZeroUsize};
+use std::{io::stdout, num::NonZeroUsize};
use crossterm::{
event::{Event, KeyCode, KeyModifiers, MouseEventKind},
@@ -27,7 +27,7 @@ use crate::{
FitOrFill,
converter::{ConvertedImage, MaybeTransferred},
kitty::{KittyDisplay, KittyReadyToDisplay},
- renderer::{RenderError, fill_default},
+ renderer::{Link, RenderError, fill_default},
skip::Skip
};
@@ -42,6 +42,8 @@ pub struct Tui {
rendered: Vec,
page_constraints: PageConstraints,
showing_help_msg: bool,
+ showing_links_menu: bool,
+ selected_link_index: usize,
is_kitty: bool,
zoom: Option
}
@@ -98,7 +100,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 detected on this page
+ links: Vec
}
#[derive(PartialEq)]
@@ -118,6 +122,8 @@ impl Tui {
rendered: vec![],
page_constraints: PageConstraints { max_wide, r_to_l },
showing_help_msg: false,
+ showing_links_menu: false,
+ selected_link_index: 0,
is_kitty,
zoom: None
}
@@ -160,6 +166,11 @@ impl Tui {
return KittyDisplay::ClearImages;
}
+ if self.showing_links_menu {
+ self.render_links_menu(frame);
+ return KittyDisplay::ClearImages;
+ }
+
if let Some(t_and_b) = full_layout.top_and_bottom {
Self::render_top_and_bottom(
t_and_b,
@@ -318,21 +329,25 @@ impl Tui {
self.last_render.unused_width = unused_width;
img_area.x += unused_width / 2;
- let to_display = page_widths
- .into_iter()
- .enumerate()
- .filter_map(|(idx, (width, img))| {
- let maybe_img =
- Self::render_single_page(frame, img, Rect { width, ..img_area });
- img_area.x += width;
- maybe_img.map(|(img, pos)| KittyReadyToDisplay {
+ let mut to_display = Vec::new();
+
+ for (idx, (width, img)) in page_widths.into_iter().enumerate() {
+ let page_rect = Rect { width, ..img_area };
+ let page_num = idx + self.page;
+
+ // Render the page
+ let maybe_img = Self::render_single_page(frame, img, page_rect);
+
+ img_area.x += width;
+ if let Some((img, pos)) = maybe_img {
+ to_display.push(KittyReadyToDisplay {
img,
- page_num: idx + self.page,
+ page_num,
pos,
display_loc: DisplayLocation::default()
- })
- })
- .collect::>();
+ });
+ }
+ }
// we want to set this at the very end so it doesn't get set somewhere halfway through and
// then the whole diffing thing messes it up
@@ -410,7 +425,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
@@ -432,7 +453,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
};
}
@@ -491,6 +513,10 @@ impl Tui {
} else {
String::new()
};
+
+ // Check if current page has links
+ 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)
@@ -500,35 +526,52 @@ 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(
+ format!("Tab: {} links", rendered[page_num].links.len()),
+ 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 {
@@ -567,6 +610,21 @@ impl Tui {
}
match c {
+ 'c' if self.showing_links_menu => {
+ // Copy the selected link URL to clipboard
+ if let Some(rendered_info) = self.rendered.get(self.page) {
+ if let Some(link) =
+ rendered_info.links.get(self.selected_link_index)
+ {
+ // Copy to clipboard using copypasta
+ use copypasta::{ClipboardContext, ClipboardProvider};
+ if let Ok(mut ctx) = ClipboardContext::new() {
+ let _ = ctx.set_contents(link.uri.clone());
+ }
+ }
+ }
+ None
+ }
'l' => self.change_page(PageChange::Next, ChangeAmount::Single),
'j' => self.change_page(PageChange::Next, ChangeAmount::WholeScreen),
'h' => self.change_page(PageChange::Prev, ChangeAmount::Single),
@@ -689,15 +747,34 @@ impl Tui {
KeyCode::Down => self.change_page(PageChange::Next, ChangeAmount::WholeScreen),
KeyCode::Left => self.change_page(PageChange::Prev, ChangeAmount::Single),
KeyCode::Up => 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,
+ self.showing_links_menu,
+ &self.bottom_msg
+ ) {
+ (false, false, BottomMessage::Help) => Some(InputAction::QuitApp),
_ => {
// When we hit escape, we just want to pop off the current message and
// show the underlying one.
self.set_msg(MessageSetting::Pop);
+ self.showing_help_msg = false;
+ self.showing_links_menu = false;
+ self.selected_link_index = 0; // Reset link selection
+ // Force a full redraw by resetting the cached rect
+ self.last_render.rect = Rect::default();
Some(InputAction::Redraw)
}
},
+ KeyCode::Enter if self.showing_links_menu => {
+ if let Some(rendered_info) = self.rendered.get(self.page) {
+ if let Some(link) = rendered_info.links.get(self.selected_link_index) {
+ if link.uri.starts_with("http") {
+ let _ = open::that(&link.uri);
+ }
+ }
+ }
+ None // Don't redraw, just execute the command
+ }
KeyCode::Enter => {
let mut default = BottomMessage::default();
std::mem::swap(&mut self.bottom_msg, &mut default);
@@ -753,6 +830,35 @@ impl Tui {
}
}
}
+ KeyCode::Tab => {
+ if self.showing_links_menu {
+ // Cycle through links when menu is open
+ if let Some(rendered_info) = self.rendered.get(self.page) {
+ if !rendered_info.links.is_empty() {
+ self.selected_link_index =
+ (self.selected_link_index + 1) % rendered_info.links.len();
+ Some(InputAction::Redraw)
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ } else {
+ // Open links menu when not already open
+ if let Some(rendered_info) = self.rendered.get(self.page) {
+ if !rendered_info.links.is_empty() {
+ self.showing_links_menu = true;
+ self.selected_link_index = 0;
+ Some(InputAction::Redraw)
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ }
+ }
_ => None
}
}
@@ -810,6 +916,7 @@ impl Tui {
// mark that we need to re-render the images
self.last_render.rect = Rect::default();
self.page = page;
+ self.selected_link_index = 0; // Reset link selection when changing pages
}
}
@@ -876,6 +983,77 @@ impl Tui {
frame.render_widget(block, block_area[1]);
frame.render_widget(help_span, block_inner);
}
+
+ pub fn render_links_menu(&self, frame: &mut Frame<'_>) {
+ let frame_area = frame.area();
+ frame.render_widget(Clear, frame_area);
+
+ let block = Block::new()
+ .title("Links in Current Page (Tab: cycle, Enter: open, c: copy, Esc: close)")
+ .padding(Padding::proportional(1))
+ .borders(Borders::ALL)
+ .border_set(border::ROUNDED)
+ .border_style(Color::Blue);
+
+ // Get links for the current page
+ let links_text = if let Some(rendered_info) = self.rendered.get(self.page) {
+ if rendered_info.links.is_empty() {
+ "No links found on this page.".to_string()
+ } else {
+ let max_link_width = 70; // Reserve space for numbering and selection indicator
+ let mut text = String::new();
+ for (i, link) in rendered_info.links.iter().enumerate() {
+ let truncated_uri = if link.uri.len() > max_link_width {
+ format!("{}...", &link.uri[..max_link_width])
+ } else {
+ link.uri.clone()
+ };
+
+ let prefix = if i == self.selected_link_index {
+ format!("> {}. ", i + 1) // Selected item gets ">" prefix
+ } else {
+ format!(" {}. ", i + 1) // Normal items get spaces
+ };
+
+ text.push_str(&format!("{prefix}{truncated_uri}\n"));
+ }
+ text
+ }
+ } else {
+ "Page not yet loaded.".to_string()
+ };
+
+ let links_span = Paragraph::new(links_text.as_str()).wrap(Wrap { trim: false });
+
+ let max_w: u16 = links_text
+ .lines()
+ .map(str::len)
+ .max()
+ .unwrap_or(30)
+ .min(80)
+ .try_into()
+ .unwrap_or(30);
+
+ let layout = Layout::horizontal([
+ Constraint::Fill(1),
+ Constraint::Length(max_w + 6),
+ Constraint::Fill(1)
+ ])
+ .split(frame_area);
+
+ let num_lines = links_text.lines().count().max(3);
+ let block_area = Layout::vertical([
+ Constraint::Fill(1),
+ Constraint::Length(u16::try_from(num_lines).unwrap_or(10) + 4),
+ Constraint::Fill(1)
+ ])
+ .split(layout[1]);
+
+ let block_inner = block.inner(block_area[1]);
+
+ frame.render_widget(block, block_area[1]);
+ frame.render_widget(links_span, block_inner);
+ }
}
static HELP_PAGE: &str = "\