mirror of
https://github.com/itsjunetime/tdf.git
synced 2026-06-02 08:01:47 -04:00
Compare commits
35 Commits
mupdf-rewrite
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| ebd902e864 | |||
| b6bc76edbb | |||
| 777705b902 | |||
| 035185a40f | |||
| 5542daffb6 | |||
| f0a6e23f8a | |||
| f0afd22ff5 | |||
| 8d65f0e3f5 | |||
| e16163efb8 | |||
| 2f4e2a54bc | |||
| 0bfacd8757 | |||
| 5c7073b31e | |||
| 061863d34c | |||
| f044a7fa4d | |||
| 475d45a6f6 | |||
| 69d5f96375 | |||
| 595f23de6f | |||
| d8ee0744b8 | |||
| 0c81e3cc3a | |||
| fc10dc8ffe | |||
| ef8ace4f35 | |||
| 6462c09030 | |||
| 54cc2125af | |||
| d00ae5c981 | |||
| 36279f5258 | |||
| 70b458207a | |||
| 1eee193d44 | |||
| d2be289e80 | |||
| 10e1f6cb9f | |||
| 7e4bee516b | |||
| aae1f9d37b | |||
| 70f3401702 | |||
| 9d2a730e40 | |||
| 8c10a3c4bc | |||
| 524c069b83 |
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup sccache
|
||||
if: github.event_name != 'release' && github.event_name != 'workflow_dispatch'
|
||||
uses: mozilla-actions/sccache-action@v0.0.6
|
||||
uses: mozilla-actions/sccache-action@v0.0.8
|
||||
- name: Configure sccache
|
||||
if: github.event_name != 'release' && github.event_name != 'workflow_dispatch'
|
||||
run: |
|
||||
@@ -26,28 +26,14 @@ jobs:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake libjpeg-dev libfontconfig1-dev libopenjp2-7-dev libopenjpip7 libopenjp2-7 libglib2.0-dev libnss3-dev libunwind-dev libgoogle-perftools-dev libboost-dev
|
||||
- name: Build newer poppler
|
||||
run: |
|
||||
wget https://poppler.freedesktop.org/poppler-23.10.0.tar.xz
|
||||
tar xf poppler-23.10.0.tar.xz
|
||||
cd poppler-23.10.0
|
||||
mkdir build && cd build
|
||||
cmake .. -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
|
||||
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache \
|
||||
-DENABLE_UNSTABLE_API_ABI_HEADERS=ON \
|
||||
-DENABLE_GPGME=OFF \
|
||||
-DENABLE_QT5=OFF \
|
||||
-DENABLE_QT6=OFF \
|
||||
-DENABLE_SPLASH=OFF \
|
||||
-DENABLE_LIBCURL=OFF
|
||||
make -j$(nproc)
|
||||
sudo make install
|
||||
sudo ldconfig
|
||||
sudo apt-get install -y libfontconfig1-dev libgoogle-perftools-dev google-perftools
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install clippy and fmt
|
||||
run: rustup component add clippy rustfmt
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
- name: Tests
|
||||
run: cargo test
|
||||
- name: Check fmt
|
||||
run: cargo fmt -- --check
|
||||
- name: Run tests
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
/target
|
||||
debug.log
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
# Unreleased
|
||||
|
||||
# v0.4.0
|
||||
|
||||
- Update to new `kittage` backend for kitty-protocol-supporting terminals (fixes many issues and improves performance significantly, see [the PR](https://github.com/itsjunetime/tdf/pull/74))
|
||||
- Use new mupdf search API for slightly better performance
|
||||
- Update ratatui(-image) dependencies
|
||||
- Allow specification of default white and black colors for rendered pdfs
|
||||
- Pause rendering every once in a while while there's a search term to enable searching across the entire document more quickly
|
||||
- Fix an issue with missing search highlights
|
||||
|
||||
# v0.3.0
|
||||
|
||||
- Update ratatui(-image) dependencies
|
||||
- Enable Ctrl+Z/Suspend functionality
|
||||
- Rewrite with mupdf as the backend for much better performance and rendering quality
|
||||
- Support easy inversion of colors via `i` keypress
|
||||
- Support for filling all available space with `f` keypress
|
||||
- Change help text at bottom into full help page
|
||||
|
||||
# v0.2.0
|
||||
|
||||
|
||||
Generated
+1153
-770
File diff suppressed because it is too large
Load Diff
+24
-15
@@ -1,16 +1,17 @@
|
||||
[package]
|
||||
name = "tdf-viewer"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
authors = ["June Welker <junewelker@gmail.com>"]
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
description = "A terminal viewer for PDFs"
|
||||
readme = "README.md"
|
||||
homepage = "https://github.com/itsjunetime/tdf"
|
||||
repository = "https://github.com/itsjunetime/tdf"
|
||||
license = "GPL-3.0-or-later"
|
||||
license = "AGPL-3.0-only"
|
||||
keywords = ["pdf", "tui", "cli", "terminal"]
|
||||
categories = ["command-line-utilities", "text-processing", "visualization"]
|
||||
default-run = "tdf"
|
||||
rust-version = "1.85"
|
||||
|
||||
[[bin]]
|
||||
name = "tdf"
|
||||
@@ -21,28 +22,35 @@ path = "src/main.rs"
|
||||
name = "tdf"
|
||||
|
||||
[dependencies]
|
||||
poppler-rs = { version = "0.24.1", default-features = false, features = ["v23_7"] }
|
||||
cairo-rs = { version = "0.20.0", default-features = false, features = ["png"] }
|
||||
# we're using this branch because it has significant performance fixes that I'm waiting on responses from the upstream devs to get upstreamed. See https://github.com/ratatui-org/ratatui/issues/1116
|
||||
ratatui = { git = "https://github.com/itsjunetime/ratatui.git" }
|
||||
# ratatui = { path = "./ratatui/ratatui" }
|
||||
# We're using this to have the vb64 feature (for faster base64 encoding, since that does take up a good bit of time when converting images to the Box<dyn ratatui_image::Protocol>. It also just includes a few more features that I'm waiting on main to upstream
|
||||
# ratatui = { path = "./ratatui/ratatui/" }
|
||||
# We're using this to have the vb64 feature (for faster base64 encoding, since that does take up a good bit of time when converting images to the `Protocol`. It also just includes a few more features that I'm waiting on main to upstream
|
||||
ratatui-image = { git = "https://github.com/itsjunetime/ratatui-image.git", branch = "vb64_on_personal", default-features = false }
|
||||
# ratatui-image = { path = "./ratatui-image", features = ["vb64"], default-features = false }
|
||||
crossterm = { version = "0.28.1", features = ["event-stream"] }
|
||||
image = { version = "0.25.1", features = ["png", "rayon"], default-features = false }
|
||||
# ratatui-image = { path = "./ratatui-image", default-features = false }
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||
# crossterm = { path = "../crossterm", features = ["event-stream"] }
|
||||
image = { version = "0.25.1", features = ["pnm", "rayon", "png"], default-features = false }
|
||||
notify = { version = "8.0.0", features = ["crossbeam-channel"] }
|
||||
tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] }
|
||||
futures-util = { version = "0.3.30", default-features = false }
|
||||
glib = "0.20.0"
|
||||
itertools = "*"
|
||||
flume = { version = "0.11.0", default-features = false, features = ["async"] }
|
||||
xflags = "0.4.0-pre.2"
|
||||
mimalloc = "0.1.43"
|
||||
nix = { version = "0.29.0", features = ["signal"] }
|
||||
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 }
|
||||
# kittage = { path = "../kittage/", features = ["crossterm-tokio", "image-crate", "log"] }
|
||||
kittage = { git = "https://github.com/itsjunetime/kittage.git", features = ["crossterm-tokio", "image-crate", "log"] }
|
||||
memmap2 = "*"
|
||||
|
||||
# logging
|
||||
log = "0.4.27"
|
||||
flexi_logger = "0.31"
|
||||
|
||||
# for tracing with tokio-console
|
||||
console-subscriber = { version = "0.4.0", optional = true }
|
||||
csscolorparser = { version = "0.7.0" }
|
||||
|
||||
[profile.production]
|
||||
inherits = "release"
|
||||
@@ -52,9 +60,11 @@ lto = "fat"
|
||||
default = ["nightly"]
|
||||
nightly = ["ratatui-image/vb64"]
|
||||
tracing = ["tokio/tracing", "dep:console-subscriber"]
|
||||
epub = ["mupdf/epub"]
|
||||
cbz = ["mupdf/cbz"]
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5.1", features = ["async_tokio"] }
|
||||
criterion = { version = "0.7.0", features = ["async_tokio"] }
|
||||
cpuprofiler = "0.0.4"
|
||||
|
||||
[[bench]]
|
||||
@@ -110,7 +120,6 @@ manual_ok_or = "warn"
|
||||
manual_string_new = "warn"
|
||||
many_single_char_names = "warn"
|
||||
manual_unwrap_or = "warn"
|
||||
match_on_vec_items = "warn"
|
||||
match_same_arms = "warn"
|
||||
match_wildcard_for_single_variants = "warn"
|
||||
maybe_infinite_iter = "warn"
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -13,8 +13,13 @@ Designed to be performant, very responsive, and work well with even very large P
|
||||
- Responsive details about rendering/search progress
|
||||
- Reactive layout
|
||||
|
||||
## Installation
|
||||
|
||||
1. Get the rust toolchain from [rustup.rs](https://rustup.rs)
|
||||
2. Run `rustup install nightly && cargo +nightly install --git https://github.com/itsjunetime/tdf.git`
|
||||
|
||||
## To Build
|
||||
First, you need to install the system dependencies. This includes packages such as (but not limited to) `cairo`, `gtk`, and `poppler`. If you're on linux, these will probably show up in your package manager as something like `libcairo-devel` or `cairo-dev`.
|
||||
First, you need to install the system dependencies. This will generally only include `libfontconfig` and `clang`. If you're on linux, these will probably show up in your package manager as something like `libfontconfig1-devel` or `libfontconfig-dev` and just `clang`.
|
||||
|
||||
If it turns out that you're missing one of these, it will fail to compile and tell you what library you're missing. Find the development package for that library in your package manager, install it, and try to build again. Now, the important steps:
|
||||
|
||||
@@ -22,6 +27,8 @@ If it turns out that you're missing one of these, it will fail to compile and te
|
||||
2. Clone the repo and `cd` into it
|
||||
3. Run `cargo build --release`
|
||||
|
||||
The binary should then be found at `./target/release/tdf`.
|
||||
|
||||
## Why in the world would you use this?
|
||||
|
||||
I dunno. Just for fun, mostly.
|
||||
@@ -30,4 +37,4 @@ I dunno. Just for fun, mostly.
|
||||
|
||||
Yeah, sure. Please do.
|
||||
|
||||
Please note, though, that all contributions will be treated as licensed under MPL-2.0. This is so that we can relicense to MPL-2.0 at some point in the future if we manage to move away from poppler as a backend (since that is the only dependency, at time of writing, which requires the GPLv3 license).
|
||||
Please note, though, that all contributions will be treated as licensed under MPL-2.0.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
mod utils;
|
||||
|
||||
const BLACK: i32 = 0;
|
||||
const WHITE: i32 = i32::from_be_bytes([0, 0xff, 0xff, 0xff]);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
#[cfg(feature = "tracing")]
|
||||
@@ -9,5 +12,5 @@ async fn main() {
|
||||
.nth(1)
|
||||
.expect("Please enter a file to profile");
|
||||
|
||||
utils::render_doc(file).await;
|
||||
utils::render_doc(file, None, BLACK, WHITE).await;
|
||||
}
|
||||
|
||||
+43
-14
@@ -6,15 +6,15 @@ use std::{
|
||||
time::{SystemTime, UNIX_EPOCH}
|
||||
};
|
||||
|
||||
use criterion::{criterion_group, criterion_main, profiler::Profiler, BenchmarkId, Criterion};
|
||||
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main, profiler::Profiler};
|
||||
use futures_util::StreamExt;
|
||||
use tdf::{
|
||||
converter::{ConvertedPage, ConverterMsg},
|
||||
renderer::{fill_default, PageInfo, RenderInfo}
|
||||
renderer::{PageInfo, RenderInfo, fill_default}
|
||||
};
|
||||
use utils::{
|
||||
handle_converter_msg, handle_renderer_msg, render_doc, start_all_rendering,
|
||||
start_converting_loop, start_rendering_loop, RenderState
|
||||
RenderState, handle_converter_msg, handle_renderer_msg, render_doc, start_all_rendering,
|
||||
start_converting_loop, start_rendering_loop
|
||||
};
|
||||
|
||||
const FILES: [&str; 3] = [
|
||||
@@ -23,11 +23,14 @@ const FILES: [&str; 3] = [
|
||||
"benches/geotopo.pdf"
|
||||
];
|
||||
|
||||
const BLACK: i32 = 0;
|
||||
const WHITE: i32 = i32::from_be_bytes([0, 0xff, 0xff, 0xff]);
|
||||
|
||||
fn render_full(c: &mut Criterion) {
|
||||
for file in FILES {
|
||||
c.bench_with_input(BenchmarkId::new("render_full", file), &file, |b, &file| {
|
||||
b.to_async(tokio::runtime::Runtime::new().unwrap())
|
||||
.iter(|| render_doc(file))
|
||||
.iter(|| render_doc(file, None, BLACK, WHITE))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -39,7 +42,7 @@ fn render_to_first_page(c: &mut Criterion) {
|
||||
&file,
|
||||
|b, &file| {
|
||||
b.to_async(tokio::runtime::Runtime::new().unwrap())
|
||||
.iter(|| render_first_page(file))
|
||||
.iter(|| render_first_page(file, BLACK, WHITE))
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -48,7 +51,7 @@ fn render_to_first_page(c: &mut Criterion) {
|
||||
fn only_converting(c: &mut Criterion) {
|
||||
for file in FILES {
|
||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||
let all_rendered = runtime.block_on(render_all_files(file));
|
||||
let all_rendered = runtime.block_on(render_all_files(file, BLACK, WHITE));
|
||||
|
||||
c.bench_with_input(
|
||||
BenchmarkId::new("only_converting", file),
|
||||
@@ -61,14 +64,40 @@ fn only_converting(c: &mut Criterion) {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn render_first_page(path: impl AsRef<Path>) {
|
||||
fn search_short_common(c: &mut Criterion) {
|
||||
for file in FILES {
|
||||
c.bench_with_input(
|
||||
BenchmarkId::new("search_short_common", file),
|
||||
&file,
|
||||
|b, &file| {
|
||||
b.to_async(tokio::runtime::Runtime::new().unwrap())
|
||||
.iter(|| render_doc(file, Some("an"), BLACK, WHITE))
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn search_long_rare(c: &mut Criterion) {
|
||||
for file in FILES {
|
||||
c.bench_with_input(
|
||||
BenchmarkId::new("search_long_rare", file),
|
||||
&file,
|
||||
|b, &file| {
|
||||
b.to_async(tokio::runtime::Runtime::new().unwrap())
|
||||
.iter(|| render_doc(file, Some("this is long and rare"), BLACK, WHITE))
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn render_first_page(path: impl AsRef<Path>, black: i32, white: i32) {
|
||||
let RenderState {
|
||||
mut from_render_rx,
|
||||
mut from_converter_rx,
|
||||
mut pages,
|
||||
mut to_converter_tx,
|
||||
to_render_tx
|
||||
} = start_all_rendering(path);
|
||||
} = start_all_rendering(path, black, white);
|
||||
|
||||
// we only want to render until the first page is ready to be printed
|
||||
while pages.iter().all(Option::is_none) {
|
||||
@@ -88,16 +117,16 @@ pub async fn render_first_page(path: impl AsRef<Path>) {
|
||||
drop(to_render_tx);
|
||||
}
|
||||
|
||||
async fn render_all_files(path: &'static str) -> Vec<PageInfo> {
|
||||
let (mut from_render_rx, to_render_tx) = start_rendering_loop(path);
|
||||
async fn render_all_files(path: &'static str, black: i32, white: i32) -> Vec<PageInfo> {
|
||||
let (mut from_render_rx, to_render_tx) = start_rendering_loop(path, black, white);
|
||||
let mut pages = Vec::<Option<PageInfo>>::new();
|
||||
|
||||
while let Some(info) = from_render_rx.next().await {
|
||||
match info.expect("Renderer ran into an error while rendering") {
|
||||
RenderInfo::Reloaded => (),
|
||||
RenderInfo::Reloaded | RenderInfo::SearchResults { .. } => (),
|
||||
RenderInfo::NumPages(num) => fill_default(&mut pages, num),
|
||||
RenderInfo::Page(page) => {
|
||||
let num = page.page;
|
||||
let num = page.page_num;
|
||||
pages[num] = Some(page);
|
||||
}
|
||||
};
|
||||
@@ -175,6 +204,6 @@ impl Profiler for CpuProfiler {
|
||||
criterion_group!(
|
||||
name = benches;
|
||||
config = Criterion::default().sample_size(40).with_profiler(CpuProfiler);
|
||||
targets = render_full, render_to_first_page, only_converting
|
||||
targets = render_full, render_to_first_page, only_converting, search_short_common, search_long_rare
|
||||
);
|
||||
criterion_main!(benches);
|
||||
|
||||
+41
-18
@@ -1,13 +1,13 @@
|
||||
use std::{hint::black_box, path::Path};
|
||||
|
||||
use crossterm::terminal::WindowSize;
|
||||
use flume::{r#async::RecvStream, unbounded, Sender};
|
||||
use flume::{Sender, r#async::RecvStream, unbounded};
|
||||
use futures_util::stream::StreamExt as _;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui_image::picker::{Picker, ProtocolType};
|
||||
use tdf::{
|
||||
converter::{run_conversion_loop, ConvertedPage, ConverterMsg},
|
||||
renderer::{fill_default, start_rendering, RenderError, RenderInfo, RenderNotif}
|
||||
converter::{ConvertedPage, ConverterMsg, run_conversion_loop},
|
||||
renderer::{RenderError, RenderInfo, RenderNotif, fill_default, start_rendering}
|
||||
};
|
||||
|
||||
pub fn handle_renderer_msg(
|
||||
@@ -21,8 +21,8 @@ pub fn handle_renderer_msg(
|
||||
to_converter_tx.send(ConverterMsg::NumPages(num)).unwrap();
|
||||
}
|
||||
Ok(RenderInfo::Page(info)) => to_converter_tx.send(ConverterMsg::AddImg(info)).unwrap(),
|
||||
// We can ignore the `Reloaded` variant 'cause that's only used to send info to the TUI
|
||||
Ok(RenderInfo::Reloaded) => (),
|
||||
// We can ignore the these variants 'cause they're only used to send info to the TUI
|
||||
Ok(RenderInfo::Reloaded | RenderInfo::SearchResults { .. }) => (),
|
||||
Err(e) => panic!("Got error from renderer: {e:?}")
|
||||
}
|
||||
}
|
||||
@@ -37,13 +37,13 @@ pub fn handle_converter_msg(
|
||||
|
||||
pages[num] = Some(page);
|
||||
|
||||
let num_got = pages.iter().filter(|p| p.is_some()).count();
|
||||
let first_none = pages.iter().position(Option::is_none);
|
||||
|
||||
// we have to tell it to jump to a certain page so that it will actually render it (since
|
||||
// it only renders fanning out from the page that we currently have selected)
|
||||
to_converter_tx
|
||||
.send(ConverterMsg::GoToPage(num_got))
|
||||
.unwrap();
|
||||
if let Some(first) = first_none {
|
||||
to_converter_tx.send(ConverterMsg::GoToPage(first)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RenderState {
|
||||
@@ -57,13 +57,15 @@ pub struct RenderState {
|
||||
const FONT_SIZE: (u16, u16) = (8, 14);
|
||||
|
||||
pub fn start_rendering_loop(
|
||||
path: impl AsRef<Path>
|
||||
path: impl AsRef<Path>,
|
||||
black: i32,
|
||||
white: i32
|
||||
) -> (
|
||||
RecvStream<'static, Result<RenderInfo, RenderError>>,
|
||||
Sender<RenderNotif>
|
||||
) {
|
||||
let pathbuf = path.as_ref().canonicalize().unwrap();
|
||||
let str_path = format!("file://{}", pathbuf.into_os_string().to_string_lossy());
|
||||
let str_path = pathbuf.into_os_string().to_string_lossy().to_string();
|
||||
|
||||
let (to_render_tx, from_main_rx) = unbounded();
|
||||
let (to_main_tx, from_render_rx) = unbounded();
|
||||
@@ -77,8 +79,6 @@ pub fn start_rendering_loop(
|
||||
width: columns * FONT_SIZE.0
|
||||
};
|
||||
|
||||
std::thread::spawn(move || start_rendering(&str_path, to_main_tx, from_main_rx, size));
|
||||
|
||||
let main_area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -87,6 +87,21 @@ pub fn start_rendering_loop(
|
||||
};
|
||||
to_render_tx.send(RenderNotif::Area(main_area)).unwrap();
|
||||
|
||||
let cell_height_px = size.height / size.rows;
|
||||
let cell_width_px = size.width / size.columns;
|
||||
std::thread::spawn(move || {
|
||||
start_rendering(
|
||||
&str_path,
|
||||
to_main_tx,
|
||||
from_main_rx,
|
||||
cell_height_px,
|
||||
cell_width_px,
|
||||
tdf::PrerenderLimit::All,
|
||||
black,
|
||||
white
|
||||
)
|
||||
});
|
||||
|
||||
let from_render_rx = from_render_rx.into_stream();
|
||||
(from_render_rx, to_render_tx)
|
||||
}
|
||||
@@ -107,15 +122,17 @@ pub fn start_converting_loop(
|
||||
to_main_tx,
|
||||
from_main_rx,
|
||||
picker,
|
||||
prerender
|
||||
prerender,
|
||||
// just assume shms work for now, who cares
|
||||
true
|
||||
));
|
||||
|
||||
let from_converter_rx = from_converter_rx.into_stream();
|
||||
(from_converter_rx, to_converter_tx)
|
||||
}
|
||||
|
||||
pub fn start_all_rendering(path: impl AsRef<Path>) -> RenderState {
|
||||
let (from_render_rx, to_render_tx) = start_rendering_loop(path);
|
||||
pub fn start_all_rendering(path: impl AsRef<Path>, black: i32, white: i32) -> RenderState {
|
||||
let (from_render_rx, to_render_tx) = start_rendering_loop(path, black, white);
|
||||
let (from_converter_rx, to_converter_tx) = start_converting_loop(20);
|
||||
|
||||
let pages: Vec<Option<ConvertedPage>> = Vec::new();
|
||||
@@ -129,14 +146,20 @@ pub fn start_all_rendering(path: impl AsRef<Path>) -> RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn render_doc(path: impl AsRef<Path>) {
|
||||
pub async fn render_doc(path: impl AsRef<Path>, search_term: Option<&str>, black: i32, white: i32) {
|
||||
let RenderState {
|
||||
mut from_render_rx,
|
||||
mut from_converter_rx,
|
||||
mut pages,
|
||||
mut to_converter_tx,
|
||||
to_render_tx
|
||||
} = start_all_rendering(path);
|
||||
} = start_all_rendering(path, black, white);
|
||||
|
||||
if let Some(term) = search_term {
|
||||
to_render_tx
|
||||
.send(RenderNotif::Search(term.to_owned()))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
while pages.is_empty() || pages.iter().any(Option::is_none) {
|
||||
tokio::select! {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
+1
-1
Submodule ratatui updated: 1166bebf44...47c200fb7f
+1
-1
Submodule ratatui-image updated: 53a788e0cb...375a9e190a
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 1. Pull the git source of poppler
|
||||
# 2. cd poppler
|
||||
# 3. git checkout poppler-23.07.0
|
||||
# 4. mkdir build
|
||||
# 5. cd build
|
||||
# 6. cmake .. -DENABLE_GPGME=OFF -DENABLE_QT5=OFF -DENABLE_QT6=OFF -DENABLE_BOOST=OFF -DBUILD_SHARED_LIBS=OFF
|
||||
# 7. cmake --build . --parallel $(nproc)
|
||||
env SYSTEM_DEPS_POPPLER_GLIB_LINK=static \
|
||||
SYSTEM_DEPS_POPPLER_GLIB_NO_PKG_CONFIG=1 \
|
||||
SYSTEM_DEPS_POPPLER_GLIB_SEARCH_NATIVE=/path/to/poppler/build/glib \
|
||||
SYSTEM_DEPS_POPPLER_GLIB_LIB=poppler-glib \
|
||||
cargo perf --bin for_profiling --
|
||||
+131
-28
@@ -1,13 +1,58 @@
|
||||
use std::{
|
||||
num::{NonZeroU32, NonZeroUsize},
|
||||
time::{SystemTime, UNIX_EPOCH}
|
||||
};
|
||||
|
||||
use flume::{Receiver, SendError, Sender, TryRecvError};
|
||||
use futures_util::stream::StreamExt;
|
||||
use image::ImageFormat;
|
||||
use itertools::Itertools;
|
||||
use ratatui_image::{picker::Picker, protocol::Protocol, Resize};
|
||||
use image::DynamicImage;
|
||||
use kittage::NumberOrId;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui_image::{
|
||||
Resize,
|
||||
picker::{Picker, ProtocolType},
|
||||
protocol::Protocol
|
||||
};
|
||||
use rayon::iter::ParallelIterator;
|
||||
|
||||
use crate::renderer::{fill_default, PageInfo, RenderError};
|
||||
use crate::{
|
||||
renderer::{PageInfo, RenderError, fill_default},
|
||||
skip::InterleavedAroundWithMax
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MaybeTransferred {
|
||||
NotYet(kittage::image::Image<'static>),
|
||||
Transferred(kittage::ImageId)
|
||||
}
|
||||
|
||||
pub enum ConvertedImage {
|
||||
Generic(Protocol),
|
||||
Kitty {
|
||||
img: MaybeTransferred,
|
||||
cell_w: u16,
|
||||
cell_h: u16
|
||||
}
|
||||
}
|
||||
|
||||
impl ConvertedImage {
|
||||
pub fn w_h(&self) -> (u16, u16) {
|
||||
match self {
|
||||
Self::Generic(prot) => {
|
||||
let a = prot.area();
|
||||
(a.width, a.height)
|
||||
}
|
||||
Self::Kitty {
|
||||
img: _,
|
||||
cell_w,
|
||||
cell_h
|
||||
} => (*cell_w, *cell_h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConvertedPage {
|
||||
pub page: Protocol,
|
||||
pub page: ConvertedImage,
|
||||
pub num: usize,
|
||||
pub num_results: usize
|
||||
}
|
||||
@@ -22,17 +67,21 @@ pub async fn run_conversion_loop(
|
||||
sender: Sender<Result<ConvertedPage, RenderError>>,
|
||||
receiver: Receiver<ConverterMsg>,
|
||||
mut picker: Picker,
|
||||
prerender: usize
|
||||
prerender: usize,
|
||||
shms_work: bool
|
||||
) -> Result<(), SendError<Result<ConvertedPage, RenderError>>> {
|
||||
let mut images = vec![];
|
||||
let mut page: usize = 0;
|
||||
let pid = std::process::id();
|
||||
|
||||
fn next_page(
|
||||
images: &mut [Option<PageInfo>],
|
||||
picker: &mut Picker,
|
||||
page: usize,
|
||||
iteration: &mut usize,
|
||||
prerender: usize
|
||||
prerender: usize,
|
||||
pid: u32,
|
||||
shms_work: bool
|
||||
) -> Result<Option<ConvertedPage>, RenderError> {
|
||||
if images.is_empty() || *iteration >= prerender {
|
||||
return Ok(None);
|
||||
@@ -43,51 +92,97 @@ pub async fn run_conversion_loop(
|
||||
let idx_start = page.saturating_sub(prerender / 2);
|
||||
let idx_end = idx_start.saturating_add(prerender).min(images.len());
|
||||
|
||||
// If there's none to render, then why bother.
|
||||
let Some(idx_end) = NonZeroUsize::new(idx_end) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// then we go through all the indices available to us and find the first one that has an
|
||||
// image available to steal
|
||||
let Some((page_info, new_iter)) = (idx_start..page)
|
||||
.interleave(page..idx_end)
|
||||
let Some((page_info, new_iter, page_num)) =
|
||||
InterleavedAroundWithMax::new(page, idx_start, idx_end)
|
||||
.enumerate()
|
||||
.skip(*iteration)
|
||||
.find_map(|(i_idx, p_idx)| images[p_idx].take().map(|p| (p, i_idx)))
|
||||
.take(prerender)
|
||||
// .skip(*iteration)
|
||||
.find_map(|(i_idx, p_idx)| images[p_idx].take().map(|p| (p, i_idx, p_idx)))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let img_area = page_info.img_data.area;
|
||||
let mut dyn_img = image::load_from_memory_with_format(
|
||||
&page_info.img_data.pixels,
|
||||
image::ImageFormat::Pnm
|
||||
)
|
||||
.map_err(|e| RenderError::Converting(format!("Can't load image: {e}")))?;
|
||||
|
||||
let dyn_img =
|
||||
image::load_from_memory_with_format(&page_info.img_data.data, ImageFormat::Png)
|
||||
.map_err(|e| {
|
||||
RenderError::Render(format!("Couldn't convert Vec<u8> to DynamicImage: {e}"))
|
||||
})?;
|
||||
match dyn_img {
|
||||
DynamicImage::ImageRgb8(ref mut img) =>
|
||||
for quad in &*page_info.result_rects {
|
||||
img.par_enumerate_pixels_mut()
|
||||
.filter(|(x, y, _)| {
|
||||
*x > quad.ul_x && *x < quad.lr_x && *y > quad.ul_y && *y < quad.lr_y
|
||||
})
|
||||
.for_each(|(_, _, px)| px.0[2] = px.0[2].saturating_sub(u8::MAX / 2));
|
||||
},
|
||||
_ => unreachable!()
|
||||
};
|
||||
|
||||
// We don't actually want to Crop this image, but we've already
|
||||
// verified (with the ImageSurface stuff) that the image is the correct
|
||||
// size for the area given, so to save ratatui the work of having to
|
||||
// resize it, we tell them to crop it to fit.
|
||||
let txt_img = picker
|
||||
let img_area = Rect {
|
||||
width: page_info.img_data.cell_w,
|
||||
height: page_info.img_data.cell_h,
|
||||
x: 0,
|
||||
y: 0
|
||||
};
|
||||
|
||||
let txt_img = match picker.protocol_type() {
|
||||
ProtocolType::Kitty => {
|
||||
let rn = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
|
||||
let mut img = if shms_work {
|
||||
kittage::image::Image::shm_from(
|
||||
dyn_img,
|
||||
&format!("__tdf_kittage_{pid}_page_{rn}_{page_num}")
|
||||
)
|
||||
.map_err(|e| RenderError::Converting(format!("Couldn't write to shm: {e}")))?
|
||||
} else {
|
||||
kittage::image::Image::from(dyn_img)
|
||||
};
|
||||
|
||||
img.num_or_id = NumberOrId::Id(NonZeroU32::new(page_num as u32 + 1).unwrap());
|
||||
ConvertedImage::Kitty {
|
||||
img: MaybeTransferred::NotYet(img),
|
||||
cell_w: page_info.img_data.cell_w,
|
||||
cell_h: page_info.img_data.cell_h
|
||||
}
|
||||
}
|
||||
_ => ConvertedImage::Generic(
|
||||
picker
|
||||
.new_protocol(dyn_img, img_area, Resize::None)
|
||||
.map_err(|e| {
|
||||
RenderError::Render(format!(
|
||||
RenderError::Converting(format!(
|
||||
"Couldn't convert DynamicImage to ratatui image: {e}"
|
||||
))
|
||||
})?;
|
||||
})?
|
||||
)
|
||||
};
|
||||
|
||||
// update the iteration to the iteration that we stole this image from
|
||||
*iteration = new_iter;
|
||||
|
||||
Ok(Some(ConvertedPage {
|
||||
page: txt_img,
|
||||
num: page_info.page,
|
||||
num_results: page_info.search_results
|
||||
num: page_info.page_num,
|
||||
num_results: page_info.result_rects.len()
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_notif(msg: ConverterMsg, images: &mut Vec<Option<PageInfo>>, page: &mut usize) {
|
||||
match msg {
|
||||
ConverterMsg::AddImg(img) => {
|
||||
let page_num = img.page;
|
||||
let page_num = img.page_num;
|
||||
images[page_num] = Some(img);
|
||||
}
|
||||
ConverterMsg::NumPages(n_pages) => {
|
||||
@@ -111,7 +206,15 @@ pub async fn run_conversion_loop(
|
||||
Err(TryRecvError::Disconnected) => return Ok(())
|
||||
}
|
||||
|
||||
match next_page(&mut images, &mut picker, page, &mut iteration, prerender) {
|
||||
match next_page(
|
||||
&mut images,
|
||||
&mut picker,
|
||||
page,
|
||||
&mut iteration,
|
||||
prerender,
|
||||
pid,
|
||||
shms_work
|
||||
) {
|
||||
Ok(None) => break,
|
||||
Ok(Some(img)) => sender.send(Ok(img))?,
|
||||
Err(e) => sender.send(Err(e))?
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
use std::{io::Write, num::NonZeroU32};
|
||||
|
||||
use crossterm::{
|
||||
cursor::MoveTo,
|
||||
event::EventStream,
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode}
|
||||
};
|
||||
use image::DynamicImage;
|
||||
use kittage::{
|
||||
AsyncInputReader, ImageDimensions, ImageId, NumberOrId, PixelFormat,
|
||||
action::Action,
|
||||
delete::{ClearOrDelete, DeleteConfig, WhichToDelete},
|
||||
display::{CursorMovementPolicy, DisplayConfig, DisplayLocation},
|
||||
error::TransmitError,
|
||||
image::Image,
|
||||
medium::Medium
|
||||
};
|
||||
use ratatui::layout::Position;
|
||||
|
||||
use crate::converter::MaybeTransferred;
|
||||
|
||||
pub struct KittyReadyToDisplay<'tui> {
|
||||
pub img: &'tui mut MaybeTransferred,
|
||||
pub page_num: usize,
|
||||
pub pos: Position,
|
||||
pub display_loc: DisplayLocation
|
||||
}
|
||||
|
||||
pub enum KittyDisplay<'tui> {
|
||||
NoChange,
|
||||
ClearImages,
|
||||
DisplayImages(Vec<KittyReadyToDisplay<'tui>>)
|
||||
}
|
||||
|
||||
pub struct DbgWriter<W: Write> {
|
||||
w: W,
|
||||
#[cfg(debug_assertions)]
|
||||
buf: String
|
||||
}
|
||||
|
||||
impl<W: Write> Write for DbgWriter<W> {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if let Ok(s) = std::str::from_utf8(buf) {
|
||||
self.buf.push_str(s);
|
||||
}
|
||||
}
|
||||
self.w.write(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
log::debug!("Writing to kitty: {:?}", self.buf);
|
||||
self.buf.clear();
|
||||
}
|
||||
self.w.flush()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_action<'image, 'data, 'es>(
|
||||
action: Action<'image, 'data>,
|
||||
ev_stream: &'es mut EventStream
|
||||
) -> Result<ImageId, TransmitError<<&'es mut EventStream as AsyncInputReader>::Error>> {
|
||||
let writer = DbgWriter {
|
||||
w: std::io::stdout().lock(),
|
||||
#[cfg(debug_assertions)]
|
||||
buf: String::new()
|
||||
};
|
||||
action
|
||||
.execute_async(writer, ev_stream)
|
||||
.await
|
||||
.map(|(_, i)| i)
|
||||
}
|
||||
|
||||
pub async fn do_shms_work(ev_stream: &mut EventStream) -> bool {
|
||||
let img = DynamicImage::new_rgb8(1, 1);
|
||||
let pid = std::process::id();
|
||||
let Ok(mut k_img) = kittage::image::Image::shm_from(img, &format!("__tdf_kittage_test_{pid}"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// apparently the terminal won't respond to queries unless they have an Id instead of a number
|
||||
k_img.num_or_id = NumberOrId::Id(NonZeroU32::new(u32::MAX).unwrap());
|
||||
|
||||
enable_raw_mode().unwrap();
|
||||
|
||||
let res = run_action(Action::Query(&k_img), ev_stream).await;
|
||||
|
||||
disable_raw_mode().unwrap();
|
||||
|
||||
res.is_ok()
|
||||
}
|
||||
|
||||
pub async fn display_kitty_images<'es>(
|
||||
display: KittyDisplay<'_>,
|
||||
ev_stream: &'es mut EventStream
|
||||
) -> Result<
|
||||
(),
|
||||
(
|
||||
Vec<usize>,
|
||||
&'static str,
|
||||
TransmitError<<&'es mut EventStream as AsyncInputReader>::Error>
|
||||
)
|
||||
> {
|
||||
let images = match display {
|
||||
KittyDisplay::NoChange => return Ok(()),
|
||||
KittyDisplay::DisplayImages(_) | KittyDisplay::ClearImages => {
|
||||
run_action(
|
||||
Action::Delete(DeleteConfig {
|
||||
effect: ClearOrDelete::Clear,
|
||||
which: WhichToDelete::All
|
||||
}),
|
||||
ev_stream
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (vec![], "Couldn't clear previous images", e))?;
|
||||
|
||||
let KittyDisplay::DisplayImages(images) = display else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
images
|
||||
}
|
||||
};
|
||||
|
||||
let mut err = None;
|
||||
for KittyReadyToDisplay {
|
||||
img,
|
||||
page_num,
|
||||
pos,
|
||||
display_loc
|
||||
} in images
|
||||
{
|
||||
let config = DisplayConfig {
|
||||
location: display_loc,
|
||||
cursor_movement: CursorMovementPolicy::DontMove,
|
||||
..DisplayConfig::default()
|
||||
};
|
||||
|
||||
execute!(std::io::stdout(), MoveTo(pos.x, pos.y)).unwrap();
|
||||
|
||||
log::debug!("going to display img {img:#?}");
|
||||
log::debug!("displaying with config {config:#?}");
|
||||
|
||||
let this_err = match img {
|
||||
MaybeTransferred::NotYet(image) => {
|
||||
let mut fake_image = Image {
|
||||
num_or_id: image.num_or_id,
|
||||
format: PixelFormat::Rgb24(
|
||||
ImageDimensions {
|
||||
width: 0,
|
||||
height: 0
|
||||
},
|
||||
None
|
||||
),
|
||||
medium: Medium::Direct {
|
||||
chunk_size: None,
|
||||
data: (&[]).into()
|
||||
}
|
||||
};
|
||||
std::mem::swap(image, &mut fake_image);
|
||||
|
||||
let res = run_action(
|
||||
Action::TransmitAndDisplay {
|
||||
image: fake_image,
|
||||
config,
|
||||
placement_id: None
|
||||
},
|
||||
ev_stream
|
||||
)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(img_id) => {
|
||||
*img = MaybeTransferred::Transferred(img_id);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err((page_num, e))
|
||||
}
|
||||
}
|
||||
MaybeTransferred::Transferred(image_id) => run_action(
|
||||
Action::Display {
|
||||
image_id: *image_id,
|
||||
placement_id: *image_id,
|
||||
config
|
||||
},
|
||||
ev_stream
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| (page_num, e))
|
||||
};
|
||||
|
||||
log::debug!("this_err is {this_err:#?}");
|
||||
|
||||
if let Err((id, e)) = this_err {
|
||||
let e = err.get_or_insert_with(|| (vec![], e));
|
||||
e.0.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
match err {
|
||||
Some((replace, e)) => Err((replace, "Couldn't transfer image to the terminal", e)),
|
||||
None => Ok(())
|
||||
}
|
||||
}
|
||||
+52
@@ -1,7 +1,59 @@
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
#[global_allocator]
|
||||
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum PrerenderLimit {
|
||||
All,
|
||||
Limited(NonZeroUsize)
|
||||
}
|
||||
|
||||
pub mod converter;
|
||||
pub mod kitty;
|
||||
pub mod renderer;
|
||||
pub mod skip;
|
||||
pub mod tui;
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||||
pub enum FitOrFill {
|
||||
Fit,
|
||||
Fill
|
||||
}
|
||||
|
||||
pub struct ScaledResult {
|
||||
width: f32,
|
||||
height: f32,
|
||||
scale_factor: f32
|
||||
}
|
||||
|
||||
pub fn scale_img_for_area(
|
||||
(img_width, img_height): (f32, f32),
|
||||
(area_width, area_height): (f32, f32),
|
||||
fit_or_fill: FitOrFill
|
||||
) -> ScaledResult {
|
||||
// and get its aspect ratio
|
||||
let img_aspect_ratio = img_width / img_height;
|
||||
|
||||
// Then we get the full pixel dimensions of the area provided to us, and the aspect ratio
|
||||
// of that area
|
||||
let area_aspect_ratio = area_width / area_height;
|
||||
|
||||
// and get the ratio that this page would have to be scaled by to fit perfectly within the
|
||||
// area provided to us.
|
||||
// we do this first by comparing the aspec ratio of the page with the aspect ratio of the
|
||||
// area to fit it within. If the aspect ratio of the page is larger, then we need to scale
|
||||
// the width of the page to fill perfectly within the height of the area. Otherwise, we
|
||||
// scale the height to fit perfectly. The dimension that _is not_ scaled to fit perfectly
|
||||
// is scaled by the same factor as the dimension that _is_ scaled perfectly.
|
||||
let scale_factor = match (img_aspect_ratio > area_aspect_ratio, fit_or_fill) {
|
||||
(true, FitOrFill::Fit) | (false, FitOrFill::Fill) => area_width / img_width,
|
||||
(false, FitOrFill::Fit) | (true, FitOrFill::Fill) => area_height / img_height
|
||||
};
|
||||
|
||||
ScaledResult {
|
||||
width: img_width * scale_factor,
|
||||
height: img_height * scale_factor,
|
||||
scale_factor
|
||||
}
|
||||
}
|
||||
|
||||
+339
-104
@@ -1,42 +1,61 @@
|
||||
use core::error::Error;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
ffi::OsString,
|
||||
io::{stdout, Read, Write},
|
||||
num::NonZeroUsize,
|
||||
io::{BufReader, Read, Stdout, stdout},
|
||||
num::{NonZeroU32, NonZeroUsize},
|
||||
path::PathBuf
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
event::EventStream,
|
||||
execute,
|
||||
terminal::{
|
||||
disable_raw_mode, enable_raw_mode, window_size, EndSynchronizedUpdate,
|
||||
EnterAlternateScreen, LeaveAlternateScreen
|
||||
EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
|
||||
enable_raw_mode, window_size
|
||||
}
|
||||
};
|
||||
use futures_util::{stream::StreamExt, FutureExt};
|
||||
use glib::{LogField, LogLevel, LogWriterOutput};
|
||||
use flexi_logger::FileSpec;
|
||||
use flume::{Sender, r#async::RecvStream};
|
||||
use futures_util::{FutureExt, stream::StreamExt};
|
||||
use kittage::{
|
||||
action::Action,
|
||||
delete::{ClearOrDelete, DeleteConfig, WhichToDelete},
|
||||
error::{TerminalError, TransmitError}
|
||||
};
|
||||
use notify::{Event, EventKind, RecursiveMode, Watcher};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use ratatui_image::picker::Picker;
|
||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||
use ratatui_image::{
|
||||
FontSize,
|
||||
picker::{Picker, ProtocolType}
|
||||
};
|
||||
use tdf::{
|
||||
converter::{run_conversion_loop, ConvertedPage, ConverterMsg},
|
||||
PrerenderLimit,
|
||||
converter::{ConvertedPage, ConverterMsg, run_conversion_loop},
|
||||
kitty::{KittyDisplay, display_kitty_images, do_shms_work, run_action},
|
||||
renderer::{self, RenderError, RenderInfo, RenderNotif},
|
||||
tui::{BottomMessage, InputAction, MessageSetting, Tui}
|
||||
};
|
||||
|
||||
// Dummy struct for easy errors in main
|
||||
#[derive(Debug)]
|
||||
struct BadTermSizeStdin(String);
|
||||
struct WrappedErr(Cow<'static, str>);
|
||||
|
||||
impl std::fmt::Display for BadTermSizeStdin {
|
||||
impl std::fmt::Display for WrappedErr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BadTermSizeStdin {}
|
||||
impl std::fmt::Debug for WrappedErr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Display::fmt(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WrappedErr {}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
async fn main() -> Result<(), WrappedErr> {
|
||||
#[cfg(feature = "tracing")]
|
||||
console_subscriber::init();
|
||||
|
||||
@@ -46,14 +65,58 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
optional -r,--r-to-l r_to_l: bool
|
||||
/// The maximum number of pages to display together, horizontally, at a time
|
||||
optional -m,--max-wide max_wide: NonZeroUsize
|
||||
/// Fullscreen the pdf (hide document name, page count, etc)
|
||||
optional -f,--fullscreen fullscreen: bool
|
||||
/// The number of pages to prerender surrounding the currently-shown page; 0 means no
|
||||
/// limit. By default, there is no limit.
|
||||
optional -p,--prerender prerender: usize
|
||||
/// Custom white color, specified in css format (e.g. "FFFFFF" or "rgb(255, 255, 255)")
|
||||
optional -w,--white-color white: String
|
||||
/// Custom black color, specified in css format (e.g "000000" or "rgb(0, 0, 0)")
|
||||
optional -b,--black-color black: String
|
||||
/// PDF file to read
|
||||
required file: PathBuf
|
||||
};
|
||||
|
||||
let path = flags.file.canonicalize()?;
|
||||
let path = flags
|
||||
.file
|
||||
.canonicalize()
|
||||
.map_err(|e| WrappedErr(format!("Cannot canonicalize provided file: {e}").into()))?;
|
||||
|
||||
let black =
|
||||
parse_color_to_i32(flags.black_color.as_deref().unwrap_or("000000")).map_err(|e| {
|
||||
WrappedErr(
|
||||
format!("Couldn't parse black color: {e} - is it formatted like a CSS color?")
|
||||
.into()
|
||||
)
|
||||
})?;
|
||||
|
||||
let white =
|
||||
parse_color_to_i32(flags.white_color.as_deref().unwrap_or("FFFFFF")).map_err(|e| {
|
||||
WrappedErr(
|
||||
format!("Couldn't parse white color: {e} - is it formatted like a CSS color?")
|
||||
.into()
|
||||
)
|
||||
})?;
|
||||
|
||||
// need to keep it around throughout the lifetime of the program, but don't rly need to use it.
|
||||
// Just need to make sure it doesn't get dropped yet.
|
||||
let mut maybe_logger = None;
|
||||
|
||||
if std::env::var("RUST_LOG").is_ok() {
|
||||
maybe_logger = Some(
|
||||
flexi_logger::Logger::try_with_env()
|
||||
.map_err(|e| WrappedErr(format!("Couldn't create initial logger: {e}").into()))?
|
||||
.log_to_file(FileSpec::try_from("./debug.log").map_err(|e| {
|
||||
WrappedErr(format!("Couldn't create FileSpec for logger: {e}").into())
|
||||
})?)
|
||||
.start()
|
||||
.map_err(|e| WrappedErr(format!("Can't start logger: {e}").into()))?
|
||||
);
|
||||
}
|
||||
|
||||
let (watch_to_render_tx, render_rx) = flume::unbounded();
|
||||
let tui_tx = watch_to_render_tx.clone();
|
||||
let to_renderer = watch_to_render_tx.clone();
|
||||
|
||||
let (render_tx, tui_rx) = flume::unbounded();
|
||||
let watch_to_tui_tx = render_tx.clone();
|
||||
@@ -62,9 +125,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
watch_to_tui_tx,
|
||||
watch_to_render_tx,
|
||||
path.file_name()
|
||||
.ok_or("Path does not have a last component??")?
|
||||
.ok_or(WrappedErr("Path does not have a last component??".into()))?
|
||||
.to_owned()
|
||||
))?;
|
||||
))
|
||||
.map_err(|e| WrappedErr(format!("Couldn't start watching the provided file: {e}").into()))?;
|
||||
|
||||
// So we have to watch the parent directory of the file that we are interested in because the
|
||||
// `notify` library works on inodes, and if the file is deleted, that inode is gone as well,
|
||||
@@ -74,116 +138,193 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// opinion on this clear
|
||||
// (https://github.com/notify-rs/notify/issues/113#issuecomment-281836995) so whatever, guess
|
||||
// we have to do this annoying workaround.
|
||||
watcher.watch(
|
||||
watcher
|
||||
.watch(
|
||||
path.parent().expect("The root directory is not a PDF"),
|
||||
RecursiveMode::NonRecursive
|
||||
)?;
|
||||
)
|
||||
.map_err(|e| WrappedErr(format!("Can't watch the provided file: {e}").into()))?;
|
||||
|
||||
// TODO: Handle non-utf8 file names? Maybe by constructing a CString and passing that in to the
|
||||
// poppler stuff instead of a rust string?
|
||||
let file_path = format!("file://{}", path.clone().into_os_string().to_string_lossy());
|
||||
// mupdf stuff instead of a rust string?
|
||||
let file_path = path.clone().into_os_string().to_string_lossy().to_string();
|
||||
|
||||
let mut window_size = window_size()?;
|
||||
let mut window_size = window_size().map_err(|e| {
|
||||
WrappedErr(format!("Can't get your current terminal window size: {e}").into())
|
||||
})?;
|
||||
|
||||
if window_size.width == 0 || window_size.height == 0 {
|
||||
// send the command code to get the terminal window size
|
||||
print!("\x1b[14t");
|
||||
std::io::stdout().flush()?;
|
||||
let (w, h) = get_font_size_through_stdio()?;
|
||||
|
||||
// we need to enable raw mode here since this bit of output won't print a newline; it'll
|
||||
// just print the info it wants to tell us. So we want to get all characters as they come
|
||||
enable_raw_mode()?;
|
||||
|
||||
// read in the returned size until we hit a 't' (which indicates to us it's done)
|
||||
let input_vec = std::io::stdin()
|
||||
.bytes()
|
||||
.filter_map(Result::ok)
|
||||
.take_while(|b| *b != b't')
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// and then disable raw mode again in case we return an error in this next section
|
||||
disable_raw_mode()?;
|
||||
|
||||
let input_line = String::from_utf8(input_vec)?;
|
||||
|
||||
if input_line.starts_with("\x1b[4;") {
|
||||
// it should input it to us as `\e[4;<height>;<width>t`, so we need to split to get the h/w
|
||||
// ignore the first val
|
||||
let mut splits = input_line.split([';', 't']).skip(1);
|
||||
|
||||
window_size.height = splits
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
BadTermSizeStdin(format!(
|
||||
"Terminal responded with unparseable size response '{input_line}'"
|
||||
))
|
||||
})?
|
||||
.parse::<u16>()?;
|
||||
|
||||
window_size.width = splits
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
BadTermSizeStdin(format!(
|
||||
"Terminal responded with unparseable size response '{input_line}'"
|
||||
))
|
||||
})?
|
||||
.parse::<u16>()?;
|
||||
} else {
|
||||
return Err("Your terminal is falsely reporting a window size of 0; tdf needs an accurate window size to display graphics".into());
|
||||
}
|
||||
window_size.width = w;
|
||||
window_size.height = h;
|
||||
}
|
||||
|
||||
// We need to create `picker` on this thread because if we create it on the `renderer` thread,
|
||||
// it messes up something with user input. Input never makes it to the crossterm thing
|
||||
let picker = Picker::from_query_stdio()?;
|
||||
let picker = Picker::from_query_stdio()
|
||||
.map_err(|e| WrappedErr(match e {
|
||||
ratatui_image::errors::Errors::NoFontSize =>
|
||||
"Unable to detect your terminal's font size; this is an issue with your terminal emulator.\nPlease use a different terminal emulator or report this bug to tdf.".into(),
|
||||
e => format!("Couldn't get the necessary information to set up images: {e}").into()
|
||||
}))?;
|
||||
|
||||
// then we want to spawn off the rendering task
|
||||
// We need to use the thread::spawn API so that this exists in a thread not owned by tokio,
|
||||
// since the methods we call in `start_rendering` will panic if called in an async context
|
||||
let prerender = flags
|
||||
.prerender
|
||||
.and_then(NonZeroUsize::new)
|
||||
.map_or(PrerenderLimit::All, PrerenderLimit::Limited);
|
||||
|
||||
let cell_height_px = window_size.height / window_size.rows;
|
||||
let cell_width_px = window_size.width / window_size.columns;
|
||||
std::thread::spawn(move || {
|
||||
renderer::start_rendering(&file_path, render_tx, render_rx, window_size)
|
||||
renderer::start_rendering(
|
||||
&file_path,
|
||||
render_tx,
|
||||
render_rx,
|
||||
cell_height_px,
|
||||
cell_width_px,
|
||||
prerender,
|
||||
black,
|
||||
white
|
||||
)
|
||||
});
|
||||
|
||||
let font_size = picker.font_size();
|
||||
|
||||
let mut ev_stream = crossterm::event::EventStream::new();
|
||||
|
||||
let (to_converter, from_main) = flume::unbounded();
|
||||
let (to_main, from_converter) = flume::unbounded();
|
||||
|
||||
tokio::spawn(run_conversion_loop(to_main, from_main, picker, 20));
|
||||
let is_kitty = picker.protocol_type() == ProtocolType::Kitty;
|
||||
|
||||
let shms_work = is_kitty && do_shms_work(&mut ev_stream).await;
|
||||
|
||||
tokio::spawn(run_conversion_loop(
|
||||
to_main, from_main, picker, 20, shms_work
|
||||
));
|
||||
|
||||
let file_name = path.file_name().map_or_else(
|
||||
|| "Unknown file".into(),
|
||||
|n| n.to_string_lossy().to_string()
|
||||
);
|
||||
let mut tui = Tui::new(file_name, flags.max_wide, flags.r_to_l.unwrap_or_default());
|
||||
let tui = Tui::new(
|
||||
file_name,
|
||||
flags.max_wide,
|
||||
flags.r_to_l.unwrap_or_default(),
|
||||
is_kitty
|
||||
);
|
||||
|
||||
let backend = CrosstermBackend::new(std::io::stdout());
|
||||
let mut term = Terminal::new(backend)?;
|
||||
let mut term = Terminal::new(backend).map_err(|e| {
|
||||
WrappedErr(format!("Couldn't set up crossterm's terminal backend: {e}").into())
|
||||
})?;
|
||||
term.skip_diff(true);
|
||||
|
||||
// poppler has some annoying logging (e.g. if you request a page index out-of-bounds of a
|
||||
// document's pages, then it will return `None`, but still log to stderr with CRITICAL level),
|
||||
// so we want to just ignore all logging since this is a tui app.
|
||||
glib::log_set_writer_func(noop);
|
||||
|
||||
execute!(
|
||||
term.backend_mut(),
|
||||
EnterAlternateScreen,
|
||||
crossterm::cursor::Hide
|
||||
)?;
|
||||
enable_raw_mode()?;
|
||||
)
|
||||
.map_err(|e| {
|
||||
WrappedErr(
|
||||
format!(
|
||||
"Couldn't enter the alternate screen and hide the cursor for proper presentation: {e}"
|
||||
)
|
||||
.into()
|
||||
)
|
||||
})?;
|
||||
enable_raw_mode().map_err(|e| {
|
||||
WrappedErr(
|
||||
format!("Can't enable raw mode, which is necessary to receive input: {e}").into()
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut main_area = Tui::main_layout(&term.get_frame());
|
||||
tui_tx.send(RenderNotif::Area(main_area[1]))?;
|
||||
if is_kitty {
|
||||
run_action(
|
||||
Action::Delete(DeleteConfig {
|
||||
effect: ClearOrDelete::Delete,
|
||||
which: WhichToDelete::IdRange(NonZeroU32::new(1).unwrap()..=NonZeroU32::MAX)
|
||||
}),
|
||||
&mut ev_stream
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
WrappedErr(format!("Couldn't delete all previous images from memory: {e}").into())
|
||||
})?;
|
||||
}
|
||||
|
||||
let mut tui_rx = tui_rx.into_stream();
|
||||
let mut from_converter = from_converter.into_stream();
|
||||
let fullscreen = flags.fullscreen.unwrap_or_default();
|
||||
let main_area = Tui::main_layout(&term.get_frame(), fullscreen);
|
||||
to_renderer
|
||||
.send(RenderNotif::Area(main_area.page_area))
|
||||
.map_err(|e| {
|
||||
WrappedErr(
|
||||
format!("Couldn't inform the rendering thread of the available area: {e}").into()
|
||||
)
|
||||
})?;
|
||||
|
||||
let tui_rx = tui_rx.into_stream();
|
||||
let from_converter = from_converter.into_stream();
|
||||
|
||||
enter_redraw_loop(
|
||||
ev_stream,
|
||||
to_renderer,
|
||||
tui_rx,
|
||||
to_converter,
|
||||
from_converter,
|
||||
fullscreen,
|
||||
tui,
|
||||
&mut term,
|
||||
main_area,
|
||||
font_size
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
WrappedErr(
|
||||
format!(
|
||||
"An unexpected error occurred while communicating between different parts of tdf: {e}"
|
||||
)
|
||||
.into()
|
||||
)
|
||||
})?;
|
||||
|
||||
execute!(
|
||||
term.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
crossterm::cursor::Show
|
||||
)
|
||||
.unwrap();
|
||||
disable_raw_mode().unwrap();
|
||||
|
||||
drop(maybe_logger);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// oh shut up clippy who cares
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
async fn enter_redraw_loop(
|
||||
mut ev_stream: EventStream,
|
||||
to_renderer: Sender<RenderNotif>,
|
||||
mut tui_rx: RecvStream<'_, Result<RenderInfo, RenderError>>,
|
||||
to_converter: Sender<ConverterMsg>,
|
||||
mut from_converter: RecvStream<'_, Result<ConvertedPage, RenderError>>,
|
||||
mut fullscreen: bool,
|
||||
mut tui: Tui,
|
||||
term: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
mut main_area: tdf::tui::RenderLayout,
|
||||
font_size: FontSize
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
loop {
|
||||
let mut needs_redraw = true;
|
||||
let next_ev = ev_stream.next().fuse();
|
||||
tokio::select! {
|
||||
// First we check if we have any keystrokes
|
||||
Some(ev) = ev_stream.next().fuse() => {
|
||||
Some(ev) = next_ev => {
|
||||
// If we can't get user input, just crash.
|
||||
let ev = ev.expect("Couldn't get any user input");
|
||||
|
||||
@@ -191,12 +332,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
None => needs_redraw = false,
|
||||
Some(action) => match action {
|
||||
InputAction::Redraw => (),
|
||||
InputAction::QuitApp => break,
|
||||
InputAction::QuitApp => return Ok(()),
|
||||
InputAction::JumpingToPage(page) => {
|
||||
tui_tx.send(RenderNotif::JumpToPage(page))?;
|
||||
to_renderer.send(RenderNotif::JumpToPage(page))?;
|
||||
to_converter.send(ConverterMsg::GoToPage(page))?;
|
||||
},
|
||||
InputAction::Search(term) => tui_tx.send(RenderNotif::Search(term))?,
|
||||
InputAction::Search(term) => to_renderer.send(RenderNotif::Search(term))?,
|
||||
InputAction::Invert => to_renderer.send(RenderNotif::Invert)?,
|
||||
InputAction::Fullscreen => fullscreen = !fullscreen,
|
||||
InputAction::SwitchRenderZoom(f_or_f) => {
|
||||
to_renderer.send(RenderNotif::SwitchFitOrFill(f_or_f)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -208,45 +354,69 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
to_converter.send(ConverterMsg::NumPages(num))?;
|
||||
},
|
||||
RenderInfo::Page(info) => {
|
||||
tui.got_num_results_on_page(info.page, info.search_results);
|
||||
tui.got_num_results_on_page(info.page_num, info.result_rects.len());
|
||||
to_converter.send(ConverterMsg::AddImg(info))?;
|
||||
},
|
||||
RenderInfo::Reloaded => tui.set_msg(MessageSetting::Some(BottomMessage::Reloaded)),
|
||||
RenderInfo::SearchResults { page_num, num_results } =>
|
||||
tui.got_num_results_on_page(page_num, num_results),
|
||||
},
|
||||
Err(e) => tui.show_error(e),
|
||||
}
|
||||
}
|
||||
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 }) => {
|
||||
tui.page_ready(page, num, num_results);
|
||||
if num == tui.page {
|
||||
needs_redraw = true;
|
||||
}
|
||||
},
|
||||
Err(e) => tui.show_error(e),
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let new_area = Tui::main_layout(&term.get_frame());
|
||||
let new_area = Tui::main_layout(&term.get_frame(), fullscreen);
|
||||
if new_area != main_area {
|
||||
main_area = new_area;
|
||||
tui_tx.send(RenderNotif::Area(main_area[1]))?;
|
||||
to_renderer.send(RenderNotif::Area(main_area.page_area))?;
|
||||
needs_redraw = true;
|
||||
}
|
||||
|
||||
if needs_redraw {
|
||||
let mut to_display = KittyDisplay::NoChange;
|
||||
term.draw(|f| {
|
||||
tui.render(f, &main_area);
|
||||
to_display = tui.render(f, &main_area, font_size);
|
||||
})?;
|
||||
execute!(stdout(), EndSynchronizedUpdate)?;
|
||||
|
||||
let maybe_err = display_kitty_images(to_display, &mut ev_stream).await;
|
||||
|
||||
if let Err((to_replace, err_desc, enum_err)) = maybe_err {
|
||||
match enum_err {
|
||||
// This is the error that kitty & ghostty provide us when they delete an
|
||||
// image due to memory constraints, so if we get it, we just fix it by
|
||||
// re-rendering so it don't display it to the user
|
||||
//
|
||||
// [TODO] maybe when we detect that an image was deleted, we probe the
|
||||
// terminal for the pages around it to see if they were deleted too and if
|
||||
// they were, we re-render them? idk
|
||||
TransmitError::Terminal(TerminalError::NoEntity(_)) => (),
|
||||
_ => tui.set_msg(MessageSetting::Some(BottomMessage::Error(format!(
|
||||
"{err_desc}: {enum_err}"
|
||||
))))
|
||||
}
|
||||
|
||||
for page_num in to_replace {
|
||||
tui.page_failed_display(page_num);
|
||||
// So that they get re-rendered and sent over again
|
||||
to_renderer.send(RenderNotif::PageNeedsReRender(page_num))?;
|
||||
}
|
||||
}
|
||||
|
||||
execute!(
|
||||
term.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
crossterm::cursor::Show
|
||||
)?;
|
||||
disable_raw_mode()?;
|
||||
|
||||
Ok(())
|
||||
execute!(stdout().lock(), EndSynchronizedUpdate)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_notify_ev(
|
||||
@@ -275,7 +445,7 @@ fn on_notify_ev(
|
||||
match ev.kind {
|
||||
EventKind::Access(_) => (),
|
||||
EventKind::Remove(_) => to_tui_tx
|
||||
.send(Err(RenderError::Render("File was deleted".into())))
|
||||
.send(Err(RenderError::Converting("File was deleted".into())))
|
||||
.unwrap(),
|
||||
// This shouldn't fail to send unless the receiver gets disconnected. If that's
|
||||
// happened, then like the main thread has panicked or something, so it doesn't matter
|
||||
@@ -287,6 +457,71 @@ fn on_notify_ev(
|
||||
}
|
||||
}
|
||||
|
||||
fn noop(_: LogLevel, _: &[LogField<'_>]) -> LogWriterOutput {
|
||||
LogWriterOutput::Handled
|
||||
fn parse_color_to_i32(cs: &str) -> Result<i32, csscolorparser::ParseColorError> {
|
||||
let color = csscolorparser::parse(cs)?;
|
||||
let [r, g, b, _] = color.to_rgba8();
|
||||
Ok(i32::from_be_bytes([0, r, g, b]))
|
||||
}
|
||||
|
||||
fn get_font_size_through_stdio() -> Result<(u16, u16), WrappedErr> {
|
||||
// we need to enable raw mode here since this bit of output won't print a newline; it'll
|
||||
// just print the info it wants to tell us. So we want to get all characters as they come
|
||||
enable_raw_mode().map_err(|e| {
|
||||
WrappedErr(
|
||||
format!("Can't enable raw mode, which is necessary to receive input: {e}").into()
|
||||
)
|
||||
})?;
|
||||
|
||||
// read in the returned size until we hit a 't' (which indicates to us it's done)
|
||||
let input_vec = BufReader::new(std::io::stdin())
|
||||
.bytes()
|
||||
.filter_map(Result::ok)
|
||||
.take_while(|b| *b != b't')
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// and then disable raw mode again in case we return an error in this next section
|
||||
disable_raw_mode().map_err(|e| {
|
||||
WrappedErr(format!("Can't put the terminal back into a normal input state: {e}").into())
|
||||
})?;
|
||||
|
||||
let input_line = String::from_utf8(input_vec).map_err(|e| {
|
||||
WrappedErr(
|
||||
format!(
|
||||
"The terminal responded to our request for its font size by providing non-utf8 data: {e}"
|
||||
)
|
||||
.into()
|
||||
)
|
||||
})?;
|
||||
let input_line = input_line
|
||||
.trim_start_matches("\x1b[4")
|
||||
.trim_start_matches(';');
|
||||
|
||||
// it should input it to us as `\e[4;<height>;<width>t`, so we need to split to get the h/w
|
||||
// ignore the first val
|
||||
let mut splits = input_line.split([';', 't']);
|
||||
|
||||
let (Some(h), Some(w)) = (splits.next(), splits.next()) else {
|
||||
return Err(WrappedErr(
|
||||
format!("Terminal responded with unparseable size response '{input_line}'").into()
|
||||
));
|
||||
};
|
||||
|
||||
let h = h.parse::<u16>().map_err(|_| {
|
||||
WrappedErr(
|
||||
format!(
|
||||
"Your terminal said its height is {h}, but that is not a 16-bit unsigned integer"
|
||||
)
|
||||
.into()
|
||||
)
|
||||
})?;
|
||||
let w = w.parse::<u16>().map_err(|_| {
|
||||
WrappedErr(
|
||||
format!(
|
||||
"Your terminal said its width is {w}, but that is not a 16-bit unsigned integer"
|
||||
)
|
||||
.into()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((w, h))
|
||||
}
|
||||
|
||||
+386
-256
@@ -1,62 +1,72 @@
|
||||
use std::thread;
|
||||
use std::{collections::VecDeque, num::NonZeroUsize, thread::sleep, time::Duration};
|
||||
|
||||
use cairo::{Antialias, Context, Format, Surface};
|
||||
use crossterm::terminal::WindowSize;
|
||||
use flume::{Receiver, SendError, Sender, TryRecvError};
|
||||
use itertools::Itertools;
|
||||
use poppler::{Color, Document, FindFlags, Page, Rectangle, SelectionStyle};
|
||||
use mupdf::{
|
||||
Colorspace, Document, Matrix, Page, Pixmap, Quad, TextPageOptions, text_page::SearchHitResponse
|
||||
};
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use crate::{
|
||||
FitOrFill, PrerenderLimit, ScaledResult, scale_img_for_area, skip::InterleavedAroundWithMax
|
||||
};
|
||||
|
||||
const KITTY_MAX_W_OR_H: f32 = 10_000.0;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RenderNotif {
|
||||
Area(Rect),
|
||||
JumpToPage(usize),
|
||||
PageNeedsReRender(usize),
|
||||
Search(String),
|
||||
Reload
|
||||
SwitchFitOrFill(FitOrFill),
|
||||
Reload,
|
||||
Invert
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RenderError {
|
||||
Notify(notify::Error),
|
||||
Doc(glib::Error),
|
||||
// Don't like storing an error as a string but it needs to be Send to send to the main thread,
|
||||
// and it's just going to be shown to the user, so whatever
|
||||
Render(String)
|
||||
Doc(mupdf::error::Error),
|
||||
Converting(String)
|
||||
}
|
||||
|
||||
pub enum RenderInfo {
|
||||
NumPages(usize),
|
||||
Page(PageInfo),
|
||||
SearchResults { page_num: usize, num_results: usize },
|
||||
Reloaded
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PageInfo {
|
||||
pub img_data: ImageData,
|
||||
pub page: usize,
|
||||
pub search_results: usize
|
||||
pub page_num: usize,
|
||||
pub result_rects: Vec<HighlightRect>
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ImageData {
|
||||
pub data: Vec<u8>,
|
||||
pub area: Rect
|
||||
pub pixels: Vec<u8>,
|
||||
pub cell_w: u16,
|
||||
pub cell_h: u16
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PrevRender {
|
||||
successful: bool,
|
||||
contained_term: Option<bool>
|
||||
num_search_found: Option<usize>
|
||||
}
|
||||
|
||||
const MUPDF_BLACK: i32 = 0;
|
||||
const MUPDF_WHITE: i32 = i32::from_be_bytes([0, 0xff, 0xff, 0xff]);
|
||||
|
||||
#[inline]
|
||||
pub fn fill_default<T: Default>(vec: &mut Vec<T>, size: usize) {
|
||||
vec.clear();
|
||||
vec.reserve(size.saturating_sub(vec.len()));
|
||||
for _ in 0..size {
|
||||
vec.push(T::default());
|
||||
}
|
||||
vec.resize_with(size, T::default);
|
||||
}
|
||||
|
||||
// this function has to be sync (non-async) because the poppler::Document needs to be held during
|
||||
// this function has to be sync (non-async) because the mupdf::Document needs to be held during
|
||||
// most of it, but that's basically just a wrapper around `*c_void` cause it's just a binding to C
|
||||
// code, so it's !Send and thus can't be held across await points. So we can't call any of the
|
||||
// async `send` or `recv` methods in this function body, since those create await points. Which
|
||||
@@ -68,34 +78,33 @@ pub fn fill_default<T: Default>(vec: &mut Vec<T>, size: usize) {
|
||||
// We're allowing passing by value here because this is only called once, at the beginning of the
|
||||
// program, and the arguments that 'should' be passed by value (`receiver` and `size`) would
|
||||
// probably be more performant if accessed by-value instead of through a reference. Probably.
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
|
||||
pub fn start_rendering(
|
||||
path: &str,
|
||||
mut sender: Sender<Result<RenderInfo, RenderError>>,
|
||||
sender: Sender<Result<RenderInfo, RenderError>>,
|
||||
receiver: Receiver<RenderNotif>,
|
||||
size: WindowSize
|
||||
col_h: u16,
|
||||
col_w: u16,
|
||||
prerender: PrerenderLimit,
|
||||
black: i32,
|
||||
white: i32
|
||||
) -> Result<(), SendError<Result<RenderInfo, RenderError>>> {
|
||||
// first, wait 'til we get told what the current starting area is so that we can set it to
|
||||
// know what to render to
|
||||
let mut area = loop {
|
||||
if let RenderNotif::Area(r) = receiver.recv().unwrap() {
|
||||
break r;
|
||||
}
|
||||
};
|
||||
|
||||
// We want this outside of 'reload so that if the doc reloads, the search term that somebody
|
||||
// set will still get highlighted in the reloaded doc
|
||||
let mut search_term = None;
|
||||
|
||||
// And although the font size could theoretically change, we aren't accounting for that right
|
||||
// now, so we just keep this out of the loop.
|
||||
let col_w = size.width / size.columns;
|
||||
let col_h = size.height / size.rows;
|
||||
// now, so we just use the values passed in.
|
||||
|
||||
let mut stored_doc = None;
|
||||
let mut invert = false;
|
||||
let mut preserved_area = None;
|
||||
let mut fit_or_fill = FitOrFill::Fit;
|
||||
|
||||
let mut need_rerender = VecDeque::new();
|
||||
|
||||
'reload: loop {
|
||||
let doc = match Document::from_file(path, None) {
|
||||
let doc = match Document::open(path) {
|
||||
Err(e) => {
|
||||
// if there's an error, tell the main loop
|
||||
sender.send(Err(RenderError::Doc(e)))?;
|
||||
@@ -125,16 +134,31 @@ pub fn start_rendering(
|
||||
}
|
||||
};
|
||||
|
||||
let n_pages = doc.n_pages() as usize;
|
||||
sender.send(Ok(RenderInfo::NumPages(n_pages)))?;
|
||||
let n_pages = match doc.page_count() {
|
||||
Ok(n) => match NonZeroUsize::new(n as usize) {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
sleep(Duration::from_secs(1));
|
||||
continue 'reload;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
sender.send(Err(RenderError::Doc(e)))?;
|
||||
// just basic backoff i think
|
||||
sleep(Duration::from_secs(1));
|
||||
continue 'reload;
|
||||
}
|
||||
};
|
||||
|
||||
sender.send(Ok(RenderInfo::NumPages(n_pages.get())))?;
|
||||
|
||||
// We're using this vec of bools to indicate which page numbers have already been rendered,
|
||||
// to support people jumping to specific pages and having quick rendering results. We
|
||||
// `split_at_mut` at 0 initially (which bascially makes `right == rendered && left == []`),
|
||||
// doing basically nothing, but if we get a notification that something has been jumped to,
|
||||
// then we can split at that page and render at both sides of it
|
||||
let mut rendered = vec![];
|
||||
fill_default::<PrevRender>(&mut rendered, n_pages);
|
||||
let mut rendered = Vec::new();
|
||||
fill_default::<PrevRender>(&mut rendered, n_pages.get());
|
||||
let mut start_point = 0;
|
||||
|
||||
// This is kinda a weird way of doing this, but if we get a notification that the area
|
||||
@@ -142,36 +166,62 @@ pub fn start_rendering(
|
||||
// document. If there was a mechanism to say 'start this for-loop over' then I would do
|
||||
// that, but I don't think such a thing exists, so this is our attempt
|
||||
'render_pages: loop {
|
||||
// next, we gotta wait 'til we get told what the current starting area is so that we can
|
||||
// set it to know what to render to
|
||||
let area = preserved_area.unwrap_or_else(|| {
|
||||
let new_area = loop {
|
||||
if let RenderNotif::Area(r) = receiver.recv().unwrap() {
|
||||
break r;
|
||||
}
|
||||
};
|
||||
preserved_area = Some(new_area);
|
||||
new_area
|
||||
});
|
||||
|
||||
let area_w = f32::from(area.width) * f32::from(col_w);
|
||||
let area_h = f32::from(area.height) * f32::from(col_h);
|
||||
|
||||
// what we do with a notif is the same regardless of if we're in the middle of
|
||||
// rendering the list of pages or we're all done
|
||||
macro_rules! handle_notif {
|
||||
($notif:ident) => {
|
||||
($notif:ident) => {{
|
||||
match $notif {
|
||||
RenderNotif::Reload => continue 'reload,
|
||||
RenderNotif::Area(new_area) => {
|
||||
let bigger =
|
||||
new_area.width > area.width || new_area.height > area.height;
|
||||
area = new_area;
|
||||
// we only want to re-render pages if the new area is greater than the old
|
||||
// one, 'cause then we might need sharper images to make it all look good.
|
||||
// If the new area is smaller, then the same high-quality-rendered images
|
||||
// will still look fine, so it's ok to leave it.
|
||||
if bigger {
|
||||
fill_default(&mut rendered, n_pages);
|
||||
RenderNotif::Invert => {
|
||||
invert = !invert;
|
||||
for page in &mut rendered {
|
||||
page.successful = false;
|
||||
}
|
||||
continue 'render_pages;
|
||||
}
|
||||
RenderNotif::Area(new_area) => {
|
||||
preserved_area = Some(new_area);
|
||||
fill_default(&mut rendered, n_pages.get());
|
||||
continue 'render_pages;
|
||||
}
|
||||
RenderNotif::SwitchFitOrFill(f_or_f) =>
|
||||
if f_or_f != fit_or_fill {
|
||||
fit_or_fill = f_or_f;
|
||||
fill_default(&mut rendered, n_pages.get());
|
||||
continue 'render_pages;
|
||||
},
|
||||
RenderNotif::JumpToPage(page) => {
|
||||
start_point = page;
|
||||
continue 'render_pages;
|
||||
}
|
||||
RenderNotif::PageNeedsReRender(page) => {
|
||||
rendered[page].successful = false;
|
||||
need_rerender.push_back(page);
|
||||
continue 'render_pages;
|
||||
}
|
||||
RenderNotif::Search(term) => {
|
||||
if term.is_empty() {
|
||||
// If the term is set to nothing, then we don't need to re-render
|
||||
// the pages wherein there were already no search results. So this
|
||||
// is a little optimization to allow that.
|
||||
for page in &mut rendered {
|
||||
if !page.successful || page.contained_term != Some(true) {
|
||||
if page.num_search_found.is_some_and(|n| n > 0) {
|
||||
page.num_search_found = Some(0);
|
||||
page.successful = false;
|
||||
}
|
||||
}
|
||||
@@ -182,43 +232,108 @@ pub fn start_rendering(
|
||||
// term, we can render them with the term, but if they don't, we
|
||||
// don't need to re-render and send it over again.
|
||||
for page in &mut rendered {
|
||||
page.contained_term = None;
|
||||
page.num_search_found = None;
|
||||
}
|
||||
search_term = Some(term);
|
||||
}
|
||||
continue 'render_pages;
|
||||
}
|
||||
}
|
||||
};
|
||||
}};
|
||||
}
|
||||
|
||||
let (left, right) = rendered.split_at_mut(start_point);
|
||||
let any_not_searched = rendered.iter().any(|r| r.num_search_found.is_none());
|
||||
|
||||
let page_iter = right
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.map(|(idx, p)| (idx + start_point, p))
|
||||
.interleave(
|
||||
left.iter_mut()
|
||||
.rev()
|
||||
.enumerate()
|
||||
.map(|(idx, p)| (start_point - (idx + 1), p))
|
||||
);
|
||||
|
||||
let area_w = f64::from(area.width) * f64::from(col_w);
|
||||
let area_h = f64::from(area.height) * f64::from(col_h);
|
||||
// This is our iterator over all the pages we want to look at and render. It uses this
|
||||
// weird 'interleave' thing to render pages on *both sides* of the currently-displayed
|
||||
// page in case they device to go forward or backwards.
|
||||
let page_iter = PopOnNext {
|
||||
inner: &mut need_rerender
|
||||
}
|
||||
.chain(InterleavedAroundWithMax::new(start_point, 0, n_pages).take(
|
||||
match (&prerender, &search_term) {
|
||||
// If the user has limited the amount of pages they want to prerender, then we
|
||||
// just do what they ask. Nice and easy.
|
||||
(PrerenderLimit::Limited(l), _) => l.get(),
|
||||
// If they haven't limited it, but we don't have any search term that we're
|
||||
// currently looking for, just go for all of it
|
||||
(PrerenderLimit::All, None) => n_pages.get(),
|
||||
// If they haven't limited it, and we DO have a search term we need to look
|
||||
// for, just do 20 so that we don't dramatically slow down the search process
|
||||
// since they've specifically initiated that and so we want it to take priority
|
||||
(PrerenderLimit::All, Some(_)) =>
|
||||
if any_not_searched {
|
||||
20
|
||||
} else {
|
||||
n_pages.get()
|
||||
},
|
||||
}
|
||||
));
|
||||
|
||||
// we go through each page
|
||||
for (num, rendered) in page_iter {
|
||||
for page_num in page_iter {
|
||||
let rendered = &mut rendered[page_num];
|
||||
|
||||
// we only want to continue if one of the following is met:
|
||||
// 1. It failed to render last time (we want to retry)
|
||||
// 2. The `contained_term` is set to None (representing '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.contained_term.is_some() {
|
||||
// 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() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
Err(e) => {
|
||||
sender.send(Err(RenderError::Doc(e)))?;
|
||||
continue;
|
||||
}
|
||||
Ok(p) => p
|
||||
};
|
||||
|
||||
// render the page
|
||||
match render_single_page_to_ctx(
|
||||
&page,
|
||||
search_term.as_deref(),
|
||||
rendered,
|
||||
invert,
|
||||
black,
|
||||
white,
|
||||
fit_or_fill,
|
||||
(area_w, area_h)
|
||||
) {
|
||||
// If that fn returned Some, that means it needed to be re-rendered for some
|
||||
// reason or another, so we're sending it here
|
||||
Ok(ctx) => {
|
||||
let w = ctx.pixmap.width();
|
||||
let h = ctx.pixmap.height();
|
||||
let cap = (w * h * u32::from(ctx.pixmap.n())) as usize + 16;
|
||||
let mut pixels = Vec::with_capacity(cap);
|
||||
if let Err(e) = ctx.pixmap.write_to(&mut pixels, mupdf::ImageFormat::PNM) {
|
||||
sender.send(Err(RenderError::Doc(e)))?;
|
||||
continue;
|
||||
};
|
||||
|
||||
log::debug!("got pixmap for page {page_num} with WxH {w}x{h}");
|
||||
|
||||
rendered.num_search_found = Some(ctx.result_rects.len());
|
||||
rendered.successful = true;
|
||||
|
||||
sender.send(Ok(RenderInfo::Page(PageInfo {
|
||||
img_data: ImageData {
|
||||
pixels,
|
||||
cell_w: (ctx.surface_w / f32::from(col_w)) as u16,
|
||||
cell_h: (ctx.surface_h / f32::from(col_h)) as u16
|
||||
},
|
||||
page_num,
|
||||
result_rects: ctx.result_rects
|
||||
})))?;
|
||||
}
|
||||
// And if we got an error, then obviously we need to propagate that
|
||||
Err(e) => sender.send(Err(RenderError::Doc(e)))?
|
||||
}
|
||||
|
||||
// check if we've been told to change the area that we're rendering to,
|
||||
// or if we're told to rerender
|
||||
match receiver.try_recv() {
|
||||
@@ -227,219 +342,234 @@ pub fn start_rendering(
|
||||
Ok(notif) => handle_notif!(notif),
|
||||
Err(TryRecvError::Empty) => ()
|
||||
};
|
||||
}
|
||||
|
||||
// We know this is in range 'cause we're iterating over it but we still just want
|
||||
// to be safe
|
||||
let Some(page) = doc.page(num as i32) else {
|
||||
sender.send(Err(RenderError::Render(format!(
|
||||
"Couldn't get page {num} ({}) of doc?",
|
||||
num as i32
|
||||
))))?;
|
||||
// Now, if we have a search term, we want to look through the rest of the document past
|
||||
// what we've just rendered (and looked at the search results of)
|
||||
if let Some(ref term) = search_term {
|
||||
let mut search_start = start_point;
|
||||
loop {
|
||||
// hmm maybe this would be nice to make configurable but whatever
|
||||
const SEARCH_AT_TIME: usize = 20;
|
||||
|
||||
// So now we want to look through all the remaining pages, starting after this
|
||||
// current one (we don't do interleaving here 'cause I'm lazy
|
||||
let page_idx = rendered[search_start..]
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
// And we only want to take max SEARCH_AT_TIME of them since we don't want
|
||||
// to block on this for *too* long
|
||||
.take(SEARCH_AT_TIME)
|
||||
// And we only want the ones that we still don't know about...
|
||||
.filter(|(_, r)| r.num_search_found.is_none())
|
||||
// And then adjust the index to be correct for the actual page number
|
||||
.map(|(idx, r)| (idx + search_start, r));
|
||||
|
||||
// then we go through each...
|
||||
for (page_num, rendered) in page_idx {
|
||||
// We get the number of results (using the function that specifically just
|
||||
// counts them instead of determining the quads of them all)
|
||||
let num_results = doc
|
||||
.load_page(page_num as i32)
|
||||
.and_then(|page| count_search_results(&page, term))
|
||||
.unwrap();
|
||||
|
||||
// And mark that whatever else was rendered last is not relevant anymore if
|
||||
// there are results that need to be rendered
|
||||
if num_results > 0 {
|
||||
rendered.successful = false;
|
||||
}
|
||||
// Mark the `contained_term` field with this updated value...
|
||||
rendered.num_search_found = Some(num_results);
|
||||
|
||||
// And send it over to the tui so that they can know and use it to
|
||||
// determine what next page to jump to
|
||||
sender.send(Ok(RenderInfo::SearchResults {
|
||||
page_num,
|
||||
num_results
|
||||
}))?;
|
||||
}
|
||||
|
||||
// then once we're done with this iteration, we increment search_start to
|
||||
// prepare for the next iteration
|
||||
search_start += SEARCH_AT_TIME;
|
||||
|
||||
// now, we want to check if we've gone past the end - if so, we go back to the
|
||||
// beginning so we can get the pages before the current one.
|
||||
if search_start > n_pages.get() {
|
||||
if start_point == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
search_start = 0;
|
||||
} else if ((search_start - SEARCH_AT_TIME) + 1..search_start)
|
||||
.contains(&start_point)
|
||||
{
|
||||
// And if we are back at the place we started, we've looked through all the
|
||||
// pages. Quit.
|
||||
break;
|
||||
}
|
||||
|
||||
match receiver.try_recv() {
|
||||
// If there are no messages left for us, just continue in this loop
|
||||
Err(TryRecvError::Empty) => (),
|
||||
Err(TryRecvError::Disconnected) => return Ok(()),
|
||||
Ok(msg) => handle_notif!(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// So now we've just *searched* all the pages but not necessarily rendered all of them.
|
||||
// So if there are any we have yet to render, we need to loop back to the beginning of
|
||||
// this loop to continue rendering all of them
|
||||
if rendered.iter().any(|r| !r.successful) && prerender == PrerenderLimit::All {
|
||||
continue;
|
||||
};
|
||||
|
||||
let rendered_with_no_results =
|
||||
rendered.successful && rendered.contained_term == Some(false);
|
||||
|
||||
// render the page
|
||||
match render_single_page_to_ctx(
|
||||
&page,
|
||||
search_term.as_deref(),
|
||||
rendered_with_no_results,
|
||||
(area_w, area_h)
|
||||
) {
|
||||
// If we've already rendered it just fine and we don't need to render it again,
|
||||
// just continue. We're all good
|
||||
Ok(None) => (),
|
||||
// If that fn returned Some, that means it needed to be re-rendered for some
|
||||
// reason or another, so we're sending it here
|
||||
Ok(Some(ctx)) => {
|
||||
// we make a potentially incorrect assumption here that writing the context
|
||||
// to a png won't fail, and mark that it all rendered correctly here before
|
||||
// spawning off the thread to do so and send it.
|
||||
rendered.contained_term = Some(ctx.num_results > 0);
|
||||
rendered.successful = true;
|
||||
|
||||
// if this is the page that the user is currently trying to look at, don't
|
||||
// bother spawning off a thread to render it to a png - it'll only slow
|
||||
// down the time til the user can see it (due to the overhead of creating a
|
||||
// thread), but we still want to spawn threads to render the other pages
|
||||
// since the effects of parallelizing that will be noticeable if the user
|
||||
// tries to move through pages more quickly
|
||||
if num == start_point {
|
||||
render_ctx_to_png(&ctx, &mut sender, (col_w, col_h), num)?;
|
||||
} else {
|
||||
let mut sender = sender.clone();
|
||||
thread::spawn(move || {
|
||||
render_ctx_to_png(&ctx, &mut sender, (col_w, col_h), num)
|
||||
});
|
||||
}
|
||||
}
|
||||
// And if we got an error, then obviously we need to propagate that
|
||||
Err(e) => sender.send(Err(RenderError::Render(e)))?
|
||||
}
|
||||
}
|
||||
|
||||
// Then once we've rendered all these pages, wait until we get another notification
|
||||
// that this doc needs to be reloaded
|
||||
loop {
|
||||
// This once returned None despite the main thing being still connected (I think, at
|
||||
// least), so I'm just being safe here
|
||||
let Ok(msg) = receiver.recv() else {
|
||||
return Ok(());
|
||||
};
|
||||
handle_notif!(msg);
|
||||
}
|
||||
|
||||
handle_notif!(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RenderedContext {
|
||||
surface: Surface,
|
||||
num_results: usize,
|
||||
surface_width: f64,
|
||||
surface_height: f64
|
||||
pixmap: Pixmap,
|
||||
surface_w: f32,
|
||||
surface_h: f32,
|
||||
result_rects: Vec<HighlightRect>
|
||||
}
|
||||
|
||||
/// SAFETY: I think this is safe because, although the backing struct for `Surface` does contain
|
||||
/// pointers to like the cairo_backend_t struct that all the cairo stuff is using, that struct is
|
||||
/// basically just a vtable, so accessing it from multiple threads *should* be safe since we're
|
||||
/// just calling the same functions with different data. The only other thing it holds reference to
|
||||
/// is a `cairo_device_t`, but that seems to be thread-safe because it's managed through ref counts
|
||||
/// and a mutex. Also, as far as I can tell from reading the source code, write_to_png_stream (the
|
||||
/// only function we call on this struct) doesn't access the device at all, so we should be fine
|
||||
/// there.
|
||||
/// We want this to be Send so that we can delegate the png writing to a separate thread (since
|
||||
/// that's the thing that takes the most time, by far, in this app).
|
||||
unsafe impl Send for RenderedContext {}
|
||||
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
fn render_single_page_to_ctx(
|
||||
page: &Page,
|
||||
search_term: Option<&str>,
|
||||
already_rendered_no_results: bool,
|
||||
(area_w, area_h): (f64, f64)
|
||||
) -> Result<Option<RenderedContext>, String> {
|
||||
let mut result_rects = search_term
|
||||
.as_ref()
|
||||
.map(|term| page.find_text_with_options(term, FindFlags::DEFAULT | FindFlags::MULTILINE))
|
||||
.unwrap_or_default();
|
||||
|
||||
// If there are no search terms on this page, and we've already rendered it with no search
|
||||
// terms, then just return none to avoid this computation
|
||||
if result_rects.is_empty() && already_rendered_no_results {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// then, get the size of the page
|
||||
let (p_width, p_height) = page.size();
|
||||
|
||||
// and get its aspect ratio
|
||||
let p_aspect_ratio = p_width / p_height;
|
||||
|
||||
// Then we get the full pixel dimensions of the area provided to us, and the aspect ratio
|
||||
// of that area
|
||||
let area_aspect_ratio = area_w / area_h;
|
||||
|
||||
// and get the ratio that this page would have to be scaled by to fit perfectly within the
|
||||
// area provided to us.
|
||||
// we do this first by comparing the aspec ratio of the page with the aspect ratio of the
|
||||
// area to fit it within. If the aspect ratio of the page is larger, then we need to scale
|
||||
// the width of the page to fill perfectly within the height of the area. Otherwise, we
|
||||
// scale the height to fit perfectly. The dimension that _is not_ scaled to fit perfectly
|
||||
// is scaled by the same factor as the dimension that _is_ scaled perfectly.
|
||||
let scale_factor = if p_aspect_ratio > area_aspect_ratio {
|
||||
area_w / p_width
|
||||
} else {
|
||||
area_h / p_height
|
||||
prev_render: &PrevRender,
|
||||
invert: bool,
|
||||
black: i32,
|
||||
white: i32,
|
||||
fit_or_fill: FitOrFill,
|
||||
(area_w, area_h): (f32, f32)
|
||||
) -> Result<RenderedContext, mupdf::error::Error> {
|
||||
let result_rects = match prev_render.num_search_found {
|
||||
None => search_page(page, search_term, 0)?,
|
||||
Some(0) => Vec::new(),
|
||||
Some(count @ 1..) => search_page(page, search_term, count)?
|
||||
};
|
||||
|
||||
let surface_width = p_width * scale_factor;
|
||||
let surface_height = p_height * scale_factor;
|
||||
// then, get the size of the page
|
||||
let bounds = page.bounds()?;
|
||||
let page_dim = (bounds.x1 - bounds.x0, bounds.y1 - bounds.y0);
|
||||
|
||||
let surface = cairo::ImageSurface::create(
|
||||
Format::Rgb16_565,
|
||||
// No matter how big you make these arguments, the image will be drawn at the same
|
||||
// size. So if you make them really big, the image will be drawn on a quarter of it. If
|
||||
// you make them really small, the image will cover more than all of the surface.
|
||||
//
|
||||
// However, that only stands as long as you don't scale the context that you place this
|
||||
// surface into. If you scale the dimensions of this image by n, then scale the context
|
||||
// by that same amount, then it'll still fit perfectly into the context, but be
|
||||
// rendered at higher quality.
|
||||
surface_width as i32,
|
||||
surface_height as i32
|
||||
)
|
||||
.map_err(|e| format!("Couldn't create ImageSurface: {e}"))?;
|
||||
surface.set_device_scale(scale_factor, scale_factor);
|
||||
let scaled = scale_img_for_area(page_dim, (area_w, area_h), fit_or_fill);
|
||||
let ScaledResult {
|
||||
width: mut surface_w,
|
||||
height: mut surface_h,
|
||||
mut scale_factor
|
||||
} = scaled;
|
||||
|
||||
let ctx = Context::new(surface).map_err(|e| format!("Couldn't create Context: {e}"))?;
|
||||
|
||||
// The default background color of PDFs (at least, I think) is white, so we need to set
|
||||
// that as the background color, then paint, then render.
|
||||
ctx.set_source_rgba(1.0, 1.0, 1.0, 1.0);
|
||||
|
||||
ctx.set_antialias(Antialias::None);
|
||||
ctx.paint()
|
||||
.map_err(|e| format!("Couldn't paint Context: {e}"))?;
|
||||
page.render(&ctx);
|
||||
|
||||
let num_results = result_rects.len();
|
||||
|
||||
if !result_rects.is_empty() {
|
||||
let mut highlight_color = Color::new();
|
||||
highlight_color.set_red((u16::MAX / 5) * 4);
|
||||
highlight_color.set_green((u16::MAX / 5) * 4);
|
||||
|
||||
let mut old_rect = Rectangle::new();
|
||||
for rect in &mut result_rects {
|
||||
// According to https://gitlab.freedesktop.org/poppler/poppler/-/issues/763, these rects
|
||||
// need to be corrected since they use different references as the y-coordinate base
|
||||
rect.set_y1(p_height - rect.y1());
|
||||
rect.set_y2(p_height - rect.y2());
|
||||
|
||||
page.render_selection(
|
||||
&ctx,
|
||||
rect,
|
||||
&mut old_rect,
|
||||
SelectionStyle::Glyph,
|
||||
&mut Color::new(),
|
||||
&mut highlight_color
|
||||
);
|
||||
}
|
||||
if surface_w > KITTY_MAX_W_OR_H || surface_h > KITTY_MAX_W_OR_H {
|
||||
let descale = (surface_w / KITTY_MAX_W_OR_H).max(surface_h / KITTY_MAX_W_OR_H);
|
||||
surface_w /= descale;
|
||||
surface_h /= descale;
|
||||
scale_factor /= descale;
|
||||
}
|
||||
|
||||
Ok(Some(RenderedContext {
|
||||
surface: ctx.target(),
|
||||
num_results,
|
||||
surface_width,
|
||||
surface_height
|
||||
}))
|
||||
let colorspace = Colorspace::device_rgb();
|
||||
let matrix = Matrix::new_scale(scale_factor, scale_factor);
|
||||
|
||||
let mut pixmap = page.to_pixmap(&matrix, &colorspace, false, false)?;
|
||||
if invert {
|
||||
pixmap.tint(white, black)?;
|
||||
} else if black != MUPDF_BLACK || white != MUPDF_WHITE {
|
||||
pixmap.tint(black, white)?;
|
||||
}
|
||||
|
||||
let (x_res, y_res) = pixmap.resolution();
|
||||
let new_x = (x_res as f32 * scale_factor) as i32;
|
||||
let new_y = (y_res as f32 * scale_factor) as i32;
|
||||
pixmap.set_resolution(new_x, new_y);
|
||||
|
||||
let result_rects = result_rects
|
||||
.into_iter()
|
||||
.map(|quad| {
|
||||
let ul_x = (quad.ul.x * scale_factor) as u32;
|
||||
let ul_y = (quad.ul.y * scale_factor) as u32;
|
||||
let lr_x = (quad.lr.x * scale_factor) as u32;
|
||||
let lr_y = (quad.lr.y * scale_factor) as u32;
|
||||
HighlightRect {
|
||||
ul_x,
|
||||
ul_y,
|
||||
lr_x,
|
||||
lr_y
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(RenderedContext {
|
||||
pixmap,
|
||||
surface_w,
|
||||
surface_h,
|
||||
result_rects
|
||||
})
|
||||
}
|
||||
|
||||
fn render_ctx_to_png(
|
||||
ctx: &RenderedContext,
|
||||
sender: &mut Sender<Result<RenderInfo, RenderError>>,
|
||||
(col_w, col_h): (u16, u16),
|
||||
page: usize
|
||||
) -> Result<(), SendError<Result<RenderInfo, RenderError>>> {
|
||||
let mut img_data = Vec::with_capacity((ctx.surface_height * ctx.surface_width) as usize);
|
||||
#[derive(Clone)]
|
||||
pub struct HighlightRect {
|
||||
pub ul_x: u32,
|
||||
pub ul_y: u32,
|
||||
pub lr_x: u32,
|
||||
pub lr_y: u32
|
||||
}
|
||||
|
||||
match ctx.surface.write_to_png(&mut img_data) {
|
||||
Err(e) => sender.send(Err(RenderError::Render(format!(
|
||||
"Couldn't write surface to png: {e}"
|
||||
)))),
|
||||
Ok(()) => sender.send(Ok(RenderInfo::Page(PageInfo {
|
||||
img_data: ImageData {
|
||||
data: img_data,
|
||||
area: Rect {
|
||||
width: ctx.surface_width as u16 / col_w,
|
||||
height: ctx.surface_height as u16 / col_h,
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
},
|
||||
page,
|
||||
search_results: ctx.num_results
|
||||
})))
|
||||
#[inline]
|
||||
fn search_page(
|
||||
page: &Page,
|
||||
search_term: Option<&str>,
|
||||
trusted_search_results: usize
|
||||
) -> Result<Vec<Quad>, mupdf::error::Error> {
|
||||
search_term
|
||||
.map(|term| {
|
||||
page.to_text_page(TextPageOptions::empty())
|
||||
.and_then(|page| {
|
||||
let mut v = Vec::with_capacity(trusted_search_results);
|
||||
page.search_cb(term, &mut v, |v, results| {
|
||||
v.extend(results.iter().cloned());
|
||||
SearchHitResponse::ContinueSearch
|
||||
})
|
||||
.map(|_| v)
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
.map(Option::unwrap_or_default)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn count_search_results(page: &Page, search_term: &str) -> Result<usize, mupdf::error::Error> {
|
||||
page.to_text_page(TextPageOptions::empty())
|
||||
.and_then(|page| {
|
||||
let mut count = 0;
|
||||
page.search_cb(search_term, &mut count, |count, results| {
|
||||
*count += results.len();
|
||||
SearchHitResponse::ContinueSearch
|
||||
})?;
|
||||
Ok(count)
|
||||
})
|
||||
}
|
||||
|
||||
struct PopOnNext<'a> {
|
||||
inner: &'a mut VecDeque<usize>
|
||||
}
|
||||
|
||||
impl<'a> Iterator for PopOnNext<'a> {
|
||||
type Item = usize;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.inner.pop_front()
|
||||
}
|
||||
}
|
||||
|
||||
+106
@@ -1,3 +1,5 @@
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
pub struct Skip {
|
||||
@@ -19,3 +21,107 @@ impl Widget for Skip {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PlusOrMinus {
|
||||
Plus,
|
||||
Minus
|
||||
}
|
||||
|
||||
pub struct InterleavedAroundWithMax {
|
||||
// starts at this number
|
||||
around: usize,
|
||||
inclusive_min: usize,
|
||||
// this iterator can only produce values in [0..max)
|
||||
exclusive_max: NonZeroUsize,
|
||||
// the next time we call `next()`, this value should be combined with `around` according to
|
||||
// `next_op`, then, after next_op is inverted, incremented if next_op was negative before being
|
||||
// inverted.
|
||||
next_change: usize,
|
||||
// How `next_change` should be applied to `around` next time `next()` is called
|
||||
next_op: PlusOrMinus
|
||||
}
|
||||
|
||||
impl InterleavedAroundWithMax {
|
||||
/// the following must hold or else this is liable to panic or produce nonsense values:
|
||||
/// - inclusive_min < exclusive_max
|
||||
/// - inclusive_min <= around <= exclusive_max
|
||||
pub fn new(around: usize, inclusive_min: usize, exclusive_max: NonZeroUsize) -> Self {
|
||||
Self {
|
||||
around,
|
||||
inclusive_min,
|
||||
exclusive_max,
|
||||
next_change: 0,
|
||||
next_op: PlusOrMinus::Minus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for InterleavedAroundWithMax {
|
||||
type Item = usize;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let actual_change = self.next_change % (self.exclusive_max.get() - self.inclusive_min);
|
||||
|
||||
let to_return = match self.next_op {
|
||||
// If we're supposed to add them and we need it to wrap, then try to add them together
|
||||
// 'cause we need special behavior if it overflows usize's limits
|
||||
PlusOrMinus::Plus => match self.around.checked_add(actual_change) {
|
||||
// If we added it and it's within the range, we're chillin
|
||||
Some(next_val) if next_val < self.exclusive_max.get() => next_val,
|
||||
// If we added it and it's not within the range, do next_val % (self.max + 1), e.g.
|
||||
// if max is 20, we were at 15, and we added 7, we should get 1 (because +5 would
|
||||
// hit the max, then 0, then 1). So adding 1 before the modulo makes it hit the
|
||||
// right numbers. And we can be sure the + here doesn't overflow 'cause we already
|
||||
// checked the `usize::MAX` up above
|
||||
Some(next_val) => (next_val % self.exclusive_max.get()) + self.inclusive_min,
|
||||
// If we added them and it would've overflowed usize::MAX, then we see how much
|
||||
// of the change would be remaining after reaching `max`
|
||||
None =>
|
||||
(actual_change - (self.exclusive_max.get() - actual_change))
|
||||
+ self.inclusive_min,
|
||||
},
|
||||
PlusOrMinus::Minus => match self.around.checked_sub(actual_change) {
|
||||
// If we can just minus it, cool cool. All is good.
|
||||
Some(next_val) if next_val >= self.inclusive_min => next_val,
|
||||
// If we can minus it but it goes below our min, then see how much below it went
|
||||
// and just manually wrap it around
|
||||
Some(next_val) => self.exclusive_max.get() - (self.inclusive_min - next_val),
|
||||
// If we can't...
|
||||
None => {
|
||||
// then we see how much of the change would be remaining after hitting the
|
||||
// minimum
|
||||
let remaining = actual_change - (self.around - self.inclusive_min);
|
||||
|
||||
// and then we take that away from the top!
|
||||
self.exclusive_max.get() - remaining
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.next_op = match self.next_op {
|
||||
PlusOrMinus::Plus => PlusOrMinus::Minus,
|
||||
PlusOrMinus::Minus => {
|
||||
self.next_change = (self.next_change + 1) % self.exclusive_max.get();
|
||||
PlusOrMinus::Plus
|
||||
}
|
||||
};
|
||||
|
||||
Some(to_return)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn iter_works() {
|
||||
let got = InterleavedAroundWithMax::new(5, 2, NonZeroUsize::new(21).unwrap())
|
||||
.take(30)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(got, vec![
|
||||
5, 6, 4, 7, 3, 8, 2, 9, 20, 10, 19, 11, 18, 12, 17, 13, 16, 14, 15, 15, 14, 16, 13, 17,
|
||||
12, 18, 11, 19, 10, 20
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+438
-151
@@ -1,41 +1,52 @@
|
||||
use std::{borrow::Cow, io::stdout, num::NonZeroUsize, rc::Rc};
|
||||
use std::{borrow::Cow, io::stdout, num::NonZeroUsize};
|
||||
|
||||
use crossterm::{
|
||||
event::{Event, KeyCode, KeyModifiers, MouseEventKind},
|
||||
execute,
|
||||
terminal::{
|
||||
disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, EnterAlternateScreen,
|
||||
LeaveAlternateScreen
|
||||
BeginSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
|
||||
enable_raw_mode
|
||||
}
|
||||
};
|
||||
use kittage::display::DisplayLocation;
|
||||
use nix::{
|
||||
sys::signal::{kill, Signal::SIGSTOP},
|
||||
sys::signal::{Signal::SIGSTOP, kill},
|
||||
unistd::Pid
|
||||
};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Flex, Layout, Rect},
|
||||
Frame,
|
||||
layout::{Constraint, Flex, Layout, Position, Rect},
|
||||
style::{Color, Style},
|
||||
text::Span,
|
||||
widgets::{Block, Borders, Padding},
|
||||
Frame
|
||||
symbols::border,
|
||||
text::{Span, Text},
|
||||
widgets::{Block, Borders, Clear, Padding}
|
||||
};
|
||||
use ratatui_image::{protocol::Protocol, Image};
|
||||
use ratatui_image::{FontSize, Image};
|
||||
|
||||
use crate::{renderer::RenderError, skip::Skip};
|
||||
use crate::{
|
||||
FitOrFill,
|
||||
converter::{ConvertedImage, MaybeTransferred},
|
||||
kitty::{KittyDisplay, KittyReadyToDisplay},
|
||||
renderer::{RenderError, fill_default},
|
||||
skip::Skip
|
||||
};
|
||||
|
||||
pub struct Tui {
|
||||
name: String,
|
||||
page: usize,
|
||||
pub page: usize,
|
||||
last_render: LastRender,
|
||||
bottom_msg: BottomMessage,
|
||||
// we use `prev_msg` to, for example, restore the 'search results' message on the bottom after
|
||||
// jumping to a specific page
|
||||
prev_msg: Option<BottomMessage>,
|
||||
rendered: Vec<RenderedInfo>,
|
||||
page_constraints: PageConstraints
|
||||
page_constraints: PageConstraints,
|
||||
showing_help_msg: bool,
|
||||
is_kitty: bool,
|
||||
zoom: Option<Zoom>
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
#[derive(Default)]
|
||||
struct LastRender {
|
||||
// Used as a way to track if we need to draw the images, to save ratatui from doing a lot of
|
||||
// diffing work
|
||||
@@ -64,12 +75,25 @@ struct PageConstraints {
|
||||
r_to_l: bool
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
struct Zoom {
|
||||
// just how much 'zoom' you have. Doesn't relate to anything specific yet, except that 0 means
|
||||
// it fills the screen (instead of fits)
|
||||
level: i16,
|
||||
// how many terminal-cells worth of content overflow the left side of the screen (and are thus
|
||||
// not displayed)
|
||||
cell_pan_from_left: u16,
|
||||
// how many terminal-cells worth of content overflow the top side of the screen (and are thus
|
||||
// not displayed)
|
||||
cell_pan_from_top: u16
|
||||
}
|
||||
|
||||
// This seems like a kinda weird struct because it holds two optionals but any representation
|
||||
// within it is valid; I think it's the best way to represent it
|
||||
#[derive(Default)]
|
||||
struct RenderedInfo {
|
||||
pub struct RenderedInfo {
|
||||
// The image, if it has been rendered by `Converter` to that struct
|
||||
img: Option<Protocol>,
|
||||
img: Option<ConvertedImage>,
|
||||
// The number of results for the current search term that have been found on this page. None if
|
||||
// we haven't checked this page yet
|
||||
// Also this isn't the most efficient representation of this value, but it's accurate, so like
|
||||
@@ -77,8 +101,14 @@ struct RenderedInfo {
|
||||
num_results: Option<usize>
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct RenderLayout {
|
||||
pub page_area: Rect,
|
||||
pub top_and_bottom: Option<(Rect, Rect)>
|
||||
}
|
||||
|
||||
impl Tui {
|
||||
pub fn new(name: String, max_wide: Option<NonZeroUsize>, r_to_l: bool) -> Tui {
|
||||
pub fn new(name: String, max_wide: Option<NonZeroUsize>, r_to_l: bool, is_kitty: bool) -> Tui {
|
||||
Self {
|
||||
name,
|
||||
page: 0,
|
||||
@@ -86,12 +116,21 @@ impl Tui {
|
||||
bottom_msg: BottomMessage::Help,
|
||||
last_render: LastRender::default(),
|
||||
rendered: vec![],
|
||||
page_constraints: PageConstraints { max_wide, r_to_l }
|
||||
page_constraints: PageConstraints { max_wide, r_to_l },
|
||||
showing_help_msg: false,
|
||||
is_kitty,
|
||||
zoom: None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn main_layout(frame: &Frame<'_>) -> Rc<[Rect]> {
|
||||
Layout::default()
|
||||
pub fn main_layout(frame: &Frame<'_>, fullscreened: bool) -> RenderLayout {
|
||||
if fullscreened {
|
||||
RenderLayout {
|
||||
page_area: frame.area(),
|
||||
top_and_bottom: None
|
||||
}
|
||||
} else {
|
||||
let layout = Layout::default()
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Fill(1),
|
||||
@@ -99,108 +138,40 @@ impl Tui {
|
||||
])
|
||||
.horizontal_margin(2)
|
||||
.vertical_margin(1)
|
||||
.split(frame.area())
|
||||
.split(frame.area());
|
||||
|
||||
RenderLayout {
|
||||
page_area: layout[1],
|
||||
top_and_bottom: Some((layout[0], layout[2]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Make a way to fill the width of the screen with one page and scroll down to view it
|
||||
pub fn render(&mut self, frame: &mut Frame<'_>, main_area: &[Rect]) {
|
||||
let top_block = Block::new()
|
||||
.padding(Padding {
|
||||
right: 2,
|
||||
left: 2,
|
||||
..Padding::default()
|
||||
})
|
||||
.borders(Borders::BOTTOM);
|
||||
|
||||
let top_area = top_block.inner(main_area[0]);
|
||||
|
||||
let page_nums_text = format!("{} / {}", self.page + 1, self.rendered.len());
|
||||
|
||||
let top_layout = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(page_nums_text.len() as u16)
|
||||
])
|
||||
.split(top_area);
|
||||
|
||||
let title = Span::styled(&self.name, Style::new().fg(Color::Cyan));
|
||||
|
||||
let page_nums = Span::styled(&page_nums_text, Style::new().fg(Color::Cyan));
|
||||
|
||||
frame.render_widget(top_block, main_area[0]);
|
||||
frame.render_widget(title, top_layout[0]);
|
||||
frame.render_widget(page_nums, top_layout[1]);
|
||||
|
||||
let bottom_block = Block::new()
|
||||
.padding(Padding {
|
||||
top: 1,
|
||||
right: 2,
|
||||
left: 2,
|
||||
bottom: 0
|
||||
})
|
||||
.borders(Borders::TOP);
|
||||
let bottom_area = bottom_block.inner(main_area[2]);
|
||||
|
||||
frame.render_widget(bottom_block, main_area[2]);
|
||||
|
||||
let rendered_str = if !self.rendered.is_empty() {
|
||||
format!(
|
||||
"Rendered: {}%",
|
||||
(self.rendered.iter().filter(|i| i.img.is_some()).count() * 100)
|
||||
/ self.rendered.len()
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let bottom_layout = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(rendered_str.len() as u16)
|
||||
])
|
||||
.split(bottom_area);
|
||||
|
||||
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 self.bottom_msg {
|
||||
BottomMessage::Help => (
|
||||
"/: Search, g: Go To Page, n: Next Search Result, N: Previous Search Result".into(),
|
||||
Color::Blue
|
||||
),
|
||||
BottomMessage::Error(ref e) => (e.as_str().into(), Color::Red),
|
||||
BottomMessage::Input(ref input_state) => (
|
||||
match input_state {
|
||||
InputCommand::GoToPage(page) => format!("Go to: {page}"),
|
||||
InputCommand::Search(s) => format!("Search: {s}")
|
||||
#[must_use]
|
||||
pub fn render<'s>(
|
||||
&'s mut self,
|
||||
frame: &mut Frame<'_>,
|
||||
full_layout: &RenderLayout,
|
||||
font_size: FontSize
|
||||
) -> KittyDisplay<'s> {
|
||||
if self.showing_help_msg {
|
||||
self.render_help_msg(frame);
|
||||
return KittyDisplay::ClearImages;
|
||||
}
|
||||
.into(),
|
||||
Color::Blue
|
||||
),
|
||||
BottomMessage::SearchResults(ref term) => {
|
||||
let num_found = self
|
||||
.rendered
|
||||
.iter()
|
||||
.filter_map(|r| r.num_results)
|
||||
.sum::<usize>();
|
||||
let num_searched = self
|
||||
.rendered
|
||||
.iter()
|
||||
.filter(|r| r.num_results.is_some())
|
||||
.count() * 100;
|
||||
(
|
||||
format!(
|
||||
"Results for '{term}': {num_found} (searched: {}%)",
|
||||
num_searched / self.rendered.len()
|
||||
)
|
||||
.into(),
|
||||
Color::Blue
|
||||
)
|
||||
|
||||
if let Some(t_and_b) = full_layout.top_and_bottom {
|
||||
Self::render_top_and_bottom(
|
||||
t_and_b,
|
||||
self.page,
|
||||
&self.rendered,
|
||||
&self.name,
|
||||
frame,
|
||||
&self.bottom_msg
|
||||
);
|
||||
}
|
||||
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]);
|
||||
|
||||
let mut img_area = main_area[1];
|
||||
let mut img_area = full_layout.page_area;
|
||||
|
||||
let size = frame.area();
|
||||
if size == self.last_render.rect {
|
||||
@@ -209,14 +180,104 @@ impl Tui {
|
||||
// be written and set to skip it so that ratatui doesn't spend a lot of time diffing it
|
||||
// each re-render
|
||||
frame.render_widget(Skip::new(true), img_area);
|
||||
KittyDisplay::NoChange
|
||||
} else {
|
||||
if let Some(ref mut zoom) = self.zoom {
|
||||
// yes this is ugly and I hate it. it's due to the limitations that currently exist
|
||||
// in the borrow checker. Once `-Zpolonius=next` is stabilized, we can rework this
|
||||
// to look like what we expect.
|
||||
// See https://github.com/rust-lang/rfcs/blob/master/text/2094-nll.md#problem-case-3-conditional-control-flow-across-functions
|
||||
// You can also rewrite this to just if an `if let` and run it under
|
||||
// `RUSTFLAGS="-Zpolonius=next"` and see that it works
|
||||
if self.rendered[self.page]
|
||||
.img
|
||||
.as_ref()
|
||||
.is_some_and(|c| matches!(c, ConvertedImage::Kitty { .. }))
|
||||
{
|
||||
let Some(ConvertedImage::Kitty {
|
||||
ref mut img,
|
||||
cell_w,
|
||||
cell_h
|
||||
}) = self.rendered[self.page].img
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
log::debug!("zoom is now {zoom:#?}");
|
||||
log::debug!("img_area is {img_area:#?}");
|
||||
|
||||
if zoom.level < 0 {
|
||||
img_area = Rect {
|
||||
width: img_area
|
||||
.width
|
||||
.saturating_sub((zoom.level * 2).unsigned_abs())
|
||||
.max(1),
|
||||
x: img_area.x + (zoom.level.unsigned_abs().min(img_area.width / 2)),
|
||||
..img_area
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("after adjustment, img_area is {img_area:#?}");
|
||||
|
||||
// Ugh I don't like this logic. I wish we could simplify it.
|
||||
let img_width = f32::from(cell_w);
|
||||
let img_height = f32::from(cell_h);
|
||||
let img_area_width = f32::from(img_area.width);
|
||||
let img_area_height = f32::from(img_area.height);
|
||||
let available_to_real_width_ratio = img_area_width / img_width;
|
||||
let available_to_real_height_ratio = img_area_height / img_height;
|
||||
|
||||
let (new_cell_width, new_cell_height) =
|
||||
if available_to_real_width_ratio > available_to_real_height_ratio {
|
||||
(img_width, img_area_height / available_to_real_width_ratio)
|
||||
} else {
|
||||
(img_area_width / available_to_real_height_ratio, img_height)
|
||||
};
|
||||
|
||||
log::debug!("new_cell stuff is {new_cell_width}x{new_cell_height}");
|
||||
|
||||
let width = (new_cell_width * f32::from(font_size.0)) as u32;
|
||||
let height = (new_cell_height * f32::from(font_size.1)) as u32;
|
||||
|
||||
self.last_render = LastRender {
|
||||
rect: size,
|
||||
pages_shown: 1,
|
||||
unused_width: 0
|
||||
};
|
||||
|
||||
zoom.cell_pan_from_left = zoom
|
||||
.cell_pan_from_left
|
||||
.min(cell_w.saturating_sub(new_cell_width as u16));
|
||||
zoom.cell_pan_from_top = zoom
|
||||
.cell_pan_from_top
|
||||
.min(cell_h.saturating_sub(new_cell_height as u16));
|
||||
|
||||
return KittyDisplay::DisplayImages(vec![KittyReadyToDisplay {
|
||||
img,
|
||||
page_num: self.page,
|
||||
pos: Position {
|
||||
x: img_area.x,
|
||||
y: img_area.y
|
||||
},
|
||||
display_loc: DisplayLocation {
|
||||
x: u32::from(zoom.cell_pan_from_left) * u32::from(font_size.0),
|
||||
y: u32::from(zoom.cell_pan_from_top) * u32::from(font_size.1),
|
||||
width,
|
||||
height,
|
||||
columns: img_area.width,
|
||||
rows: img_area.height,
|
||||
..DisplayLocation::default()
|
||||
}
|
||||
}]);
|
||||
}
|
||||
};
|
||||
|
||||
// here we calculate how many pages can fit in the available area.
|
||||
let mut test_area_w = img_area.width;
|
||||
// go through our pages, starting at the first one we want to view
|
||||
let mut page_widths = self.rendered[self.page..]
|
||||
.iter()
|
||||
// and get their indices (I know it's offset, we fix it down below when we actually
|
||||
// render each page)
|
||||
.iter_mut()
|
||||
// and get this to represent a count of how many we're looking at so far to render
|
||||
.enumerate()
|
||||
// and only take as many as are ready to be rendered
|
||||
.take_while(|(idx, page)| {
|
||||
@@ -227,9 +288,9 @@ impl Tui {
|
||||
take
|
||||
})
|
||||
// and map it to their width (in cells on the terminal, not pixels)
|
||||
.filter_map(|(idx, page)| page.img.as_ref().map(|img| (idx, img.rect().width)))
|
||||
.filter_map(|(_, page)| page.img.as_mut().map(|img| (img.w_h().0, img)))
|
||||
// and then take them as long as they won't overflow the available area.
|
||||
.take_while(|(_, width)| match test_area_w.checked_sub(*width) {
|
||||
.take_while(|(width, _)| match test_area_w.checked_sub(*width) {
|
||||
Some(new_val) => {
|
||||
test_area_w = new_val;
|
||||
true
|
||||
@@ -245,10 +306,11 @@ impl Tui {
|
||||
if page_widths.is_empty() {
|
||||
// If none are ready to render, just show the loading thing
|
||||
Self::render_loading_in(frame, img_area);
|
||||
KittyDisplay::ClearImages
|
||||
} else {
|
||||
execute!(stdout(), BeginSynchronizedUpdate).unwrap();
|
||||
|
||||
let total_width = page_widths.iter().map(|(_, w)| w).sum::<u16>();
|
||||
let total_width = page_widths.iter().map(|(w, _)| w).sum::<u16>();
|
||||
|
||||
self.last_render.pages_shown = page_widths.len();
|
||||
|
||||
@@ -256,32 +318,50 @@ impl Tui {
|
||||
self.last_render.unused_width = unused_width;
|
||||
img_area.x += unused_width / 2;
|
||||
|
||||
for (page_idx, width) in page_widths {
|
||||
// now, theoretically, when we call this, this page should *not* be None, but we do
|
||||
// have to account for that possibility since we can't `borrow` the image from self
|
||||
// when passing it in to `render_single_page` since that would be a mutable
|
||||
// reference + an immutable reference (and also we need to potentially temporarily
|
||||
// remove it from the array of rendered pages to replace it with a text-rendered
|
||||
// image)
|
||||
self.render_single_page(frame, page_idx + self.page, Rect {
|
||||
width,
|
||||
..img_area
|
||||
});
|
||||
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 {
|
||||
img,
|
||||
page_num: idx + self.page,
|
||||
pos,
|
||||
display_loc: DisplayLocation::default()
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 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
|
||||
self.last_render.rect = size;
|
||||
|
||||
KittyDisplay::DisplayImages(to_display)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_single_page(&mut self, frame: &mut Frame<'_>, page_idx: usize, img_area: Rect) {
|
||||
match self.rendered[page_idx].img {
|
||||
Some(ref mut page_img) => frame.render_widget(Image::new(page_img), img_area),
|
||||
None => Self::render_loading_in(frame, img_area)
|
||||
};
|
||||
fn render_single_page<'img>(
|
||||
frame: &mut Frame<'_>,
|
||||
page_img: &'img mut ConvertedImage,
|
||||
img_area: Rect
|
||||
) -> Option<(&'img mut MaybeTransferred, Position)> {
|
||||
match page_img {
|
||||
ConvertedImage::Generic(page_img) => {
|
||||
frame.render_widget(Image::new(page_img), img_area);
|
||||
None
|
||||
}
|
||||
ConvertedImage::Kitty {
|
||||
img,
|
||||
cell_h: _,
|
||||
cell_w: _
|
||||
} => Some((img, Position {
|
||||
x: img_area.x,
|
||||
y: img_area.y
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_loading_in(frame: &mut Frame<'_>, area: Rect) {
|
||||
@@ -314,7 +394,8 @@ impl Tui {
|
||||
|
||||
let old = self.page;
|
||||
match change {
|
||||
PageChange::Next => self.set_page((self.page + diff).min(self.rendered.len() - 1)),
|
||||
PageChange::Next =>
|
||||
self.set_page((self.page + diff).min(self.rendered.len().saturating_sub(1))),
|
||||
PageChange::Prev => self.set_page(self.page.saturating_sub(diff))
|
||||
}
|
||||
|
||||
@@ -325,13 +406,11 @@ impl Tui {
|
||||
}
|
||||
|
||||
pub fn set_n_pages(&mut self, n_pages: usize) {
|
||||
self.rendered = std::iter::from_fn(|| Some(RenderedInfo::default()))
|
||||
.take(n_pages)
|
||||
.collect();
|
||||
fill_default(&mut self.rendered, n_pages);
|
||||
self.page = self.page.min(n_pages - 1);
|
||||
}
|
||||
|
||||
pub fn page_ready(&mut self, img: Protocol, page_num: usize, num_results: usize) {
|
||||
pub fn page_ready(&mut self, img: ConvertedImage, page_num: usize, num_results: usize) {
|
||||
// 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
|
||||
@@ -339,7 +418,7 @@ impl Tui {
|
||||
if page_num >= self.page && page_num <= self.page + self.last_render.pages_shown {
|
||||
self.last_render.rect = Rect::default();
|
||||
} else {
|
||||
let img_w = img.rect().width;
|
||||
let img_w = img.w_h().0;
|
||||
if img_w <= self.last_render.unused_width {
|
||||
let num_fit = self.last_render.unused_width / img_w;
|
||||
if page_num >= self.page && (self.page + num_fit as usize) >= page_num {
|
||||
@@ -357,10 +436,101 @@ impl Tui {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn page_failed_display(&mut self, page_num: usize) {
|
||||
self.rendered[page_num].img = None;
|
||||
}
|
||||
|
||||
pub fn got_num_results_on_page(&mut self, page_num: usize, num_results: usize) {
|
||||
self.rendered[page_num].num_results = Some(num_results);
|
||||
}
|
||||
|
||||
pub fn render_top_and_bottom(
|
||||
(top_area, bottom_area): (Rect, Rect),
|
||||
page_num: usize,
|
||||
rendered: &[RenderedInfo],
|
||||
doc_name: &str,
|
||||
frame: &mut Frame<'_>,
|
||||
bottom_msg: &BottomMessage
|
||||
) {
|
||||
// use the extra space here to add some padding to the right side
|
||||
let page_nums_text = format!("{} / {} ", page_num + 1, rendered.len());
|
||||
|
||||
let top_block = Block::new()
|
||||
// use this first title to add a bit of padding to the left side
|
||||
.title_top(" ")
|
||||
.title_top(Span::styled(doc_name, Style::new().fg(Color::Cyan)))
|
||||
.title_top(
|
||||
Span::styled(&page_nums_text, Style::new().fg(Color::Cyan))
|
||||
.into_right_aligned_line()
|
||||
)
|
||||
.padding(Padding {
|
||||
bottom: 1,
|
||||
..Padding::default()
|
||||
})
|
||||
.borders(Borders::BOTTOM);
|
||||
|
||||
frame.render_widget(top_block, top_area);
|
||||
|
||||
let bottom_block = Block::new()
|
||||
.padding(Padding {
|
||||
top: 1,
|
||||
right: 2,
|
||||
left: 2,
|
||||
bottom: 0
|
||||
})
|
||||
.borders(Borders::TOP);
|
||||
let bottom_inside_block = bottom_block.inner(bottom_area);
|
||||
|
||||
frame.render_widget(bottom_block, bottom_area);
|
||||
|
||||
let rendered_str = if !rendered.is_empty() {
|
||||
format!(
|
||||
"Rendered: {}%",
|
||||
(rendered.iter().filter(|i| i.img.is_some()).count() * 100) / rendered.len()
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let bottom_layout = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(rendered_str.len() as u16)
|
||||
])
|
||||
.split(bottom_inside_block);
|
||||
|
||||
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 {
|
||||
InputCommand::GoToPage(page) => format!("Go to: {page}"),
|
||||
InputCommand::Search(s) => format!("Search: {s}")
|
||||
}
|
||||
.into(),
|
||||
Color::Blue
|
||||
),
|
||||
BottomMessage::SearchResults(term) => {
|
||||
let num_found = rendered.iter().filter_map(|r| r.num_results).sum::<usize>();
|
||||
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
|
||||
)
|
||||
}
|
||||
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]);
|
||||
}
|
||||
|
||||
pub fn handle_event(&mut self, ev: &Event) -> Option<InputAction> {
|
||||
fn jump_to_page(
|
||||
page: &mut usize,
|
||||
@@ -414,6 +584,12 @@ impl Tui {
|
||||
)));
|
||||
Some(InputAction::Redraw)
|
||||
}
|
||||
'i' => Some(InputAction::Invert),
|
||||
'?' => {
|
||||
self.showing_help_msg = true;
|
||||
Some(InputAction::Redraw)
|
||||
}
|
||||
'f' => Some(InputAction::Fullscreen),
|
||||
'n' if self.page < self.rendered.len() - 1 => {
|
||||
// TODO: If we can't find one, then maybe like block until we've verified
|
||||
// all the pages have been checked?
|
||||
@@ -469,6 +645,32 @@ impl Tui {
|
||||
self.last_render.rect = Rect::default();
|
||||
Some(InputAction::Redraw)
|
||||
}
|
||||
'z' if self.is_kitty => {
|
||||
let (zoom, f_or_f) = match self.zoom {
|
||||
None => (Some(Zoom::default()), FitOrFill::Fill),
|
||||
Some(_) => (None, FitOrFill::Fit)
|
||||
};
|
||||
self.zoom = zoom;
|
||||
self.last_render.rect = Rect::default();
|
||||
Some(InputAction::SwitchRenderZoom(f_or_f))
|
||||
}
|
||||
'o' if self.is_kitty => self.update_zoom(|z|
|
||||
// TODO: for now, we don't let people zoom in past fill-screen
|
||||
z.level = z.level.saturating_add(1).min(0)),
|
||||
'O' if self.is_kitty =>
|
||||
self.update_zoom(|z| z.level = z.level.saturating_sub(1)),
|
||||
'L' if self.is_kitty => self.update_zoom(|z| {
|
||||
z.cell_pan_from_left = z.cell_pan_from_left.saturating_add(1)
|
||||
}),
|
||||
'H' if self.is_kitty => self.update_zoom(|z| {
|
||||
z.cell_pan_from_left = z.cell_pan_from_left.saturating_sub(1)
|
||||
}),
|
||||
'J' if self.is_kitty => self.update_zoom(|z| {
|
||||
z.cell_pan_from_top = z.cell_pan_from_top.saturating_add(1)
|
||||
}),
|
||||
'K' if self.is_kitty => self.update_zoom(|z| {
|
||||
z.cell_pan_from_top = z.cell_pan_from_top.saturating_sub(1)
|
||||
}),
|
||||
_ => None
|
||||
}
|
||||
}
|
||||
@@ -485,8 +687,8 @@ 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.bottom_msg {
|
||||
BottomMessage::Help => Some(InputAction::QuitApp),
|
||||
KeyCode::Esc => match (self.showing_help_msg, &self.bottom_msg) {
|
||||
(false, BottomMessage::Help) => Some(InputAction::QuitApp),
|
||||
_ => {
|
||||
// When we hit escape, we just want to pop off the current message and
|
||||
// show the underlying one.
|
||||
@@ -515,7 +717,9 @@ impl Tui {
|
||||
Some(InputAction::JumpingToPage(zero_page))
|
||||
} else {
|
||||
self.set_msg(MessageSetting::Some(BottomMessage::Error(
|
||||
format!("Cannot jump to page {page}; there are only {rendered_len} pages in the document")
|
||||
format!(
|
||||
"Cannot jump to page {page}; there are only {rendered_len} pages in the document"
|
||||
)
|
||||
)));
|
||||
Some(InputAction::Redraw)
|
||||
}
|
||||
@@ -566,11 +770,21 @@ impl Tui {
|
||||
}
|
||||
}
|
||||
|
||||
// I want this to always return 0 'cause I just use it to return from `Self::handle_event`]
|
||||
#[expect(clippy::unnecessary_wraps)]
|
||||
fn update_zoom(&mut self, f: impl FnOnce(&mut Zoom)) -> Option<InputAction> {
|
||||
if let Some(z) = &mut self.zoom {
|
||||
f(z)
|
||||
}
|
||||
self.last_render.rect = Rect::default();
|
||||
Some(InputAction::Redraw)
|
||||
}
|
||||
|
||||
pub fn show_error(&mut self, err: RenderError) {
|
||||
self.set_msg(MessageSetting::Some(BottomMessage::Error(match err {
|
||||
RenderError::Notify(e) => format!("Auto-reload failed: {e}"),
|
||||
RenderError::Doc(e) => format!("Couldn't open document: {e}"),
|
||||
RenderError::Render(e) => format!("Couldn't render page: {e}")
|
||||
RenderError::Doc(e) => format!("Couldn't process document: {e}"),
|
||||
RenderError::Converting(e) => format!("Couldn't convert page after rendering: {e}")
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -595,16 +809,89 @@ impl Tui {
|
||||
self.prev_msg = None;
|
||||
self.bottom_msg = BottomMessage::default();
|
||||
}
|
||||
MessageSetting::Pop => self.bottom_msg = self.prev_msg.take().unwrap_or_default()
|
||||
MessageSetting::Pop =>
|
||||
if self.showing_help_msg {
|
||||
self.last_render.rect = Rect::default();
|
||||
self.showing_help_msg = false;
|
||||
} else {
|
||||
self.bottom_msg = self.prev_msg.take().unwrap_or_default();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_help_msg(&self, frame: &mut Frame<'_>) {
|
||||
let frame_area = frame.area();
|
||||
frame.render_widget(Clear, frame_area);
|
||||
|
||||
let block = Block::new()
|
||||
.title("Help")
|
||||
.padding(Padding::proportional(1))
|
||||
.borders(Borders::ALL)
|
||||
.border_set(border::ROUNDED)
|
||||
.border_style(Color::Blue);
|
||||
|
||||
let help_span = Text::raw(HELP_PAGE);
|
||||
|
||||
let max_w: u16 = HELP_PAGE
|
||||
.lines()
|
||||
.map(str::len)
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
.try_into()
|
||||
.expect("Every help text line must be shorter than u16::MAX");
|
||||
|
||||
let layout = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(max_w + 6),
|
||||
Constraint::Fill(1)
|
||||
])
|
||||
.split(frame_area);
|
||||
|
||||
let block_area = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::try_from(HELP_PAGE.lines().count()).unwrap() + 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(help_span, block_inner);
|
||||
}
|
||||
}
|
||||
|
||||
static HELP_PAGE: &str = "\
|
||||
l, h, left, right:
|
||||
Go forward/backwards a single page
|
||||
j, k, down, up:
|
||||
Go forwards/backwards a screen's worth of pages
|
||||
q, esc:
|
||||
Quit
|
||||
g:
|
||||
Go to specific page (type numbers after 'g')
|
||||
/:
|
||||
Search
|
||||
n, N:
|
||||
Next/Previous search result
|
||||
i:
|
||||
Invert colors
|
||||
f:
|
||||
Remove borders/fullscreen
|
||||
?:
|
||||
Show this page
|
||||
ctrl+z:
|
||||
Suspend & background tdf \
|
||||
";
|
||||
|
||||
pub enum InputAction {
|
||||
Redraw,
|
||||
JumpingToPage(usize),
|
||||
Search(String),
|
||||
QuitApp
|
||||
QuitApp,
|
||||
Invert,
|
||||
Fullscreen,
|
||||
SwitchRenderZoom(crate::FitOrFill)
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
|
||||
Reference in New Issue
Block a user