decoder: Use image-rs, libheif-rs, librsvg

This adds proper support for a lot of image formats including SVGs.
It will be the basis for many features to come. The technical core
is an asynchronous renderer that can arrange textures in smaller tiles
which might be generated on demand.

Closes #34, #40, #41, #49

Basis for fixing the following issues #7, #27, #36, #43, #68, #71
This commit is contained in:
Sophie Herold 2023-03-04 03:14:52 +01:00 • committed by Christopher Davis
parent 4d4bc4bb38
commit f80ec7db56
20 changed files with 3023 additions and 370 deletions

1595
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,21 +5,32 @@ edition = "2021"
rust-version = "1.65" rust-version = "1.65"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
[profile.release]
lto = true
codegen-units = 1
[dependencies] [dependencies]
anyhow = "1.0.66" anyhow = { version = "1.0.66", features = ["backtrace"] }
arc-swap = "1.6.0"
async-global-executor = "2.3.1" async-global-executor = "2.3.1"
async-std = "1.12.0" async-std = "1.12.0"
futures = "0.3.25"
futures-channel = "0.3.25"
gtk-macros = "0.3.0" gtk-macros = "0.3.0"
indexmap = "1.9.2"
kamadak-exif = "0.5.5" kamadak-exif = "0.5.5"
libgweather = "4.0.0"
log = "0.4.17" log = "0.4.17"
once_cell = "1.16.0" once_cell = "1.16.0"
pretty_env_logger = "0.4.0" pretty_env_logger = "0.4.0"
futures-channel = "0.3.25" safe-transmute = "0.11.2"
futures = "0.3.25"
svg_metadata = "0.4.3" svg_metadata = "0.4.3"
url = "2.3.1" url = "2.3.1"
libgweather = "4.0.0"
indexmap = "1.9.2" # decoders
image-rs = { package = "image", version = "*", git = "https://github.com/image-rs/image.git" }
libheif-rs = "0.17.0"
rsvg = { package = "librsvg", git = "https://gitlab.gnome.org/GNOME/librsvg.git" }
[dependencies.ashpd] [dependencies.ashpd]
version = "0.4.0-alpha.3" version = "0.4.0-alpha.3"
@ -27,7 +38,7 @@ features = ["gtk4"]
[dependencies.gtk] [dependencies.gtk]
package = "gtk4" package = "gtk4"
version = "0.6.0" version = "0.6.2"
features = ["v4_10", "xml_validation"] features = ["v4_10", "xml_validation"]
[dependencies.gio] [dependencies.gio]

View file

@ -15,6 +15,34 @@ Download bundle for:
Bundles will not be automatically updated after installation. Bundles will not be automatically updated after installation.
## Supported Formats
| Format | ICC | CICP | EXIF | XMP | Animation | Library |
|----------|-----|------|------|-----|-----------|-------------------------------|
| JPEG | ✔ | — | ✔ | ✘ | — | image-rs |
| PNG | ✔ | ✘ | ✔ | ✘ | ✘ | image-rs |
| GIF | ✘ * | — | — | ✘ | ✘ | image-rs |
| WEBP | — | — | ✔ | ✘ | ✘ | image-rs |
| AVIF | ✔ | ✔ | ✔ | ✘ | ✘ | libheif-rs + libheif (C++) |
| HEIC | ✔ | ✔ | ✔ | ✘ | ✘ | libheif-rs + libheif (C++) ** |
| TIFF | ✘ | — | ✔ | ✘ | — | image-rs |
| SVG | ✘ | — | — | ✘ * | — | librsvg |
| BMP | ✘ | — | — | — | — | image-rs |
| ICO | — | — | — | — | — | image-rs |
| PNM | — | — | — | — | — | image-rs |
| DDS | — | — | — | — | — | image-rs |
| TGA | — | — | — | — | — | image-rs |
| OpenEXR | — | — | — | — | — | image-rs |
| farbfeld | — | — | — | — | — | image-rs |
| Symbol | Meaning |
|--------|------------------------------------------------|
| * | Unclear if used in practice, needs research |
| ** | Split into plugin because of licensing issues |
| ✘ | Supported by format but not implemented yet |
| ? | Unclear if supported by format, needs research |
| ✔ | Supported |
## Building ## Building
### GNOME Builder ### GNOME Builder

View file

@ -90,6 +90,20 @@
} }
] ]
}, },
{
"name" : "libheif",
"buildsystem" : "cmake",
"config-opts": [
],
"sources" : [
{
"type" : "git",
"url" : "https://github.com/strukturag/libheif.git",
"tag" : "v1.15.1"
}
]
},
{ {
"name" : "loupe", "name" : "loupe",
"buildsystem" : "meson", "buildsystem" : "meson",

View file

@ -53,7 +53,7 @@
<object class="AdwStatusPage" id="error_page"> <object class="AdwStatusPage" id="error_page">
<property name="icon-name">image-missing-symbolic</property> <property name="icon-name">image-missing-symbolic</property>
<property name="title" translatable="yes">Could not Load Image</property> <property name="title" translatable="yes">Could not Load Image</property>
<property name="description" bind-source="LpImagePage" bind-property="error"/> <property name="description" bind-source="image" bind-property="error"/>
</object> </object>
</child> </child>
<child> <child>

View file

@ -3,10 +3,11 @@ unmaintained = "deny"
yanked = "deny" yanked = "deny"
notice = "warn" notice = "warn"
[advisories]
# A list of advisory IDs to ignore. Note that ignored advisories will still # A list of advisory IDs to ignore. Note that ignored advisories will still
# output a note when they are encountered. # output a note when they are encountered.
ignore = [ ignore = [
#"RUSTSEC-0000-0000", "RUSTSEC-2020-0071",
] ]
# This section is considered when running `cargo deny check licenses` # This section is considered when running `cargo deny check licenses`

View file

@ -8,8 +8,10 @@ project('loupe', 'rust',
i18n = import('i18n') i18n = import('i18n')
gnome = import('gnome') gnome = import('gnome')
dependency('gtk4', version: '>=4.8.0') dependency('gtk4', version: '>=4.9.4')
dependency('libadwaita-1', version: '>=1.2.0') dependency('libadwaita-1', version: '>=1.2.99')
dependency('gweather4', version: '>=4.0.0')
dependency('libheif', version: '>=1.14.2')
cargo = find_program('cargo', required: true) cargo = find_program('cargo', required: true)

148
src/decoder/formats/heif.rs Normal file
View file

@ -0,0 +1,148 @@
///! Decode using libheif-rs
use super::*;
use crate::decoder::tiling::{self, TilingStoreExt};
use crate::deps::*;
use crate::i18n::*;
use gtk::prelude::*;
use anyhow::Context;
use arc_swap::ArcSwap;
use libheif_rs::{ColorSpace, HeifContext, Plane, RgbChroma};
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug)]
pub struct Heif;
impl Heif {
pub fn new(
path: PathBuf,
updater: UpdateSender,
tiles: Arc<ArcSwap<tiling::TilingStore>>,
) -> Self {
log::debug!("Loading HEIF");
updater.spawn_error_handled(move || {
let ctx = HeifContext::read_from_file(&path.display().to_string())
.context(i18n("Failed to read image"))?;
let handle = ctx.primary_image_handle()?;
tiles.set_original_dimensions((handle.width(), handle.height()));
let rgb_chroma = if handle.luma_bits_per_pixel() > 8 {
if handle.has_alpha_channel() {
#[cfg(target_endian = "little")]
{
RgbChroma::HdrRgbaLe
}
#[cfg(target_endian = "big")]
{
RgbChroma::HdrRgbaBe
}
} else {
#[cfg(target_endian = "little")]
{
RgbChroma::HdrRgbLe
}
#[cfg(target_endian = "big")]
{
RgbChroma::HdrRgbBe
}
}
} else if handle.has_alpha_channel() {
RgbChroma::Rgba
} else {
RgbChroma::Rgb
};
let mut image = handle
.decode(ColorSpace::Rgb(rgb_chroma), None)
.context(i18n("Failed to decode image"))?;
let plane = image
.planes_mut()
.interleaved
.context("Interleaved plane does not exist")?;
let decoded = Decoded {
plane,
has_alpha_channel: handle.has_alpha_channel(),
pre_multiplied_alpha: handle.is_premultiplied_alpha(),
rgb_chroma,
};
let tile = tiling::Tile {
position: (0, 0),
zoom_level: tiling::zoom_to_level(1.),
bleed: 0,
texture: decoded.into_texture(),
};
tiles.push(tile);
Ok(())
});
Heif
}
}
pub struct Decoded<'a> {
plane: Plane<&'a mut [u8]>,
has_alpha_channel: bool,
pre_multiplied_alpha: bool,
rgb_chroma: RgbChroma,
}
impl<'a> Decoded<'a> {
pub fn into_texture(self) -> gdk::Texture {
let memory_format = match self.rgb_chroma {
RgbChroma::HdrRgbBe
| RgbChroma::HdrRgbaBe
| RgbChroma::HdrRgbLe
| RgbChroma::HdrRgbaLe => {
let transmuted: &mut [u16] =
safe_transmute::transmute_many_pedantic_mut(self.plane.data).unwrap();
// Scale HDR pixels to 16bit (they are usually 10bit or 12bit)
for pixel in transmuted.iter_mut() {
*pixel <<= 16 - self.plane.bits_per_pixel;
}
if self.has_alpha_channel {
if self.pre_multiplied_alpha {
gdk::MemoryFormat::R16g16b16a16Premultiplied
} else {
gdk::MemoryFormat::R16g16b16a16
}
} else {
gdk::MemoryFormat::R16g16b16
}
}
RgbChroma::Rgb | RgbChroma::Rgba => {
if self.has_alpha_channel {
if self.pre_multiplied_alpha {
gdk::MemoryFormat::R8g8b8a8Premultiplied
} else {
gdk::MemoryFormat::R8g8b8a8
}
} else {
gdk::MemoryFormat::R8g8b8
}
}
RgbChroma::C444 => unreachable!(),
};
let bytes = glib::Bytes::from_owned(self.plane.data.to_vec());
let tex = gdk::MemoryTexture::new(
self.plane.width as i32,
self.plane.height as i32,
memory_format,
&bytes,
self.plane.stride,
);
tex.upcast()
}
}

View file

@ -0,0 +1,156 @@
///! Decode using image-rs
use super::*;
use crate::decoder::tiling::{self, TilingStoreExt};
use crate::deps::*;
use anyhow::Context;
use arc_swap::ArcSwap;
use gtk::prelude::*;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug)]
pub struct ImageRsOther;
impl ImageRsOther {
pub fn new(
path: PathBuf,
updater: UpdateSender,
tiles: Arc<ArcSwap<tiling::TilingStore>>,
) -> Self {
updater.spawn_error_handled(move || {
let dimensions = image_rs::image_dimensions(&path).context("Failed to read image")?;
tiles.set_original_dimensions(dimensions);
let mut reader = image_rs::io::Reader::open(&path).context("Failed to open image")?;
// TODO: Set something huge?
reader.limits(image_rs::io::Limits::no_limits());
let reader = reader
.with_guessed_format()
.context("Failed to open image")?;
let dynamic_image = reader.decode().context("Failed to decode image")?;
let decoded_image = Decoded { dynamic_image };
let tile = tiling::Tile {
position: (0, 0),
zoom_level: tiling::zoom_to_level(1.),
bleed: 0,
texture: decoded_image.into_texture(),
};
tiles.push(tile);
Ok(())
});
ImageRsOther
}
}
pub struct Decoded {
dynamic_image: image_rs::DynamicImage,
}
impl Decoded {
pub fn into_texture(self) -> gdk::Texture {
let (n_bytes, memory_format, layout, data) = match self.dynamic_image {
img @ image_rs::DynamicImage::ImageLuma8(_) => {
let buffer = img.to_rgb8();
(
1,
gdk::MemoryFormat::R8g8b8,
buffer.sample_layout(),
buffer.into_raw(),
)
}
img @ image_rs::DynamicImage::ImageLumaA8(_) => {
let buffer = img.to_rgba8();
(
1,
gdk::MemoryFormat::R8g8b8a8,
buffer.sample_layout(),
buffer.into_raw(),
)
}
image_rs::DynamicImage::ImageRgb8(buffer) => (
1,
gdk::MemoryFormat::R8g8b8,
buffer.sample_layout(),
buffer.into_raw(),
),
image_rs::DynamicImage::ImageRgba8(buffer) => (
1,
gdk::MemoryFormat::R8g8b8a8,
buffer.sample_layout(),
buffer.into_raw(),
),
img @ image_rs::DynamicImage::ImageLuma16(_) => {
let buffer = img.to_rgb16();
(
2,
gdk::MemoryFormat::R16g16b16,
buffer.sample_layout(),
image_rs::DynamicImage::from(buffer).into_bytes(),
)
}
img @ image_rs::DynamicImage::ImageLumaA16(_) => {
let buffer = img.to_rgba16();
(
2,
gdk::MemoryFormat::R16g16b16a16,
buffer.sample_layout(),
image_rs::DynamicImage::from(buffer).into_bytes(),
)
}
image_rs::DynamicImage::ImageRgb16(buffer) => (
2,
gdk::MemoryFormat::R16g16b16,
buffer.sample_layout(),
image_rs::DynamicImage::from(buffer).into_bytes(),
),
image_rs::DynamicImage::ImageRgba16(buffer) => (
2,
gdk::MemoryFormat::R16g16b16a16,
buffer.sample_layout(),
image_rs::DynamicImage::from(buffer).into_bytes(),
),
image_rs::DynamicImage::ImageRgb32F(buffer) => (
4,
gdk::MemoryFormat::R32g32b32Float,
buffer.sample_layout(),
image_rs::DynamicImage::from(buffer).into_bytes(),
),
image_rs::DynamicImage::ImageRgba32F(buffer) => (
4,
gdk::MemoryFormat::R32g32b32a32Float,
buffer.sample_layout(),
image_rs::DynamicImage::from(buffer).into_bytes(),
),
img => {
let buffer = img.to_rgba16();
(
2,
gdk::MemoryFormat::R16g16b16a16,
buffer.sample_layout(),
safe_transmute::transmute_vec(buffer.into_raw()).unwrap(),
)
}
};
let bytes = glib::Bytes::from_owned(data);
gdk::MemoryTexture::new(
layout.width as i32,
layout.height as i32,
memory_format,
&bytes,
layout.height_stride * n_bytes,
)
.upcast()
}
}

View file

@ -0,0 +1,9 @@
mod heif;
mod image_rs_other;
mod svg;
pub use heif::Heif;
pub use image_rs_other::ImageRsOther;
pub use svg::Svg;
use super::{Decoder, UpdateSender};

219
src/decoder/formats/svg.rs Normal file
View file

@ -0,0 +1,219 @@
///! Decode using librsvg
use super::*;
use crate::decoder::tiling::{self, TilingStoreExt};
use crate::decoder::TileRequest;
use crate::deps::*;
use anyhow::Context;
use arc_swap::ArcSwap;
use gtk::prelude::*;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug)]
pub struct Svg {
thread_handle: std::thread::JoinHandle<()>,
current_request: Arc<std::sync::RwLock<Request>>,
}
#[derive(Default, Debug, Clone)]
enum Request {
#[default]
None,
Exit,
Tile(TileRequest),
}
impl Request {
fn is_waiting(&self) -> bool {
!matches!(self, Self::None)
}
}
impl Drop for Svg {
fn drop(&mut self) {
let mut request = self.current_request.write().unwrap();
*request = Request::Exit;
self.thread_handle.thread().unpark();
}
}
impl Svg {
pub fn new(
path: PathBuf,
updater: UpdateSender,
tiles: Arc<ArcSwap<tiling::TilingStore>>,
) -> Self {
let current_request: Arc<std::sync::RwLock<Request>> = Default::default();
let request_store = current_request.clone();
let thread_handle = updater.spawn_error_handled(move || {
let handle = rsvg::Loader::new().read_path(path)?;
let renderer = rsvg::CairoRenderer::new(&handle);
let (original_width, original_height) = svg_dimensions(&renderer);
tiles.set_original_dimensions((original_width, original_height));
loop {
let tile_request = {
let mut request = request_store.write().unwrap();
let value = request.clone();
*request = Request::None;
value
};
match tile_request {
Request::None => {
std::thread::park();
}
Request::Exit => {
log::debug!("Terminating decoder thread.");
break;
}
Request::Tile(tile_request) => {
let tiling = tiling::Tiling {
tile_size: tiling::TILE_SIZE,
original_dimensions: (original_width, original_height),
zoom: tile_request.zoom,
bleed: 2,
};
let relevant_tiles = tiling.relevant_tiles(&tile_request.viewport);
for tile_instructions in relevant_tiles {
if request_store.read().unwrap().is_waiting() {
break;
}
if tiles
.load()
.contains(tile_request.zoom, tile_instructions.coordinates)
{
continue;
}
let area = tile_instructions.area_with_bleed();
let surface = cairo::ImageSurface::create(
cairo::Format::ARgb32,
area.width() as i32,
area.height() as i32,
)?;
let context = cairo::Context::new(surface.clone())?;
let (total_width, total_height) =
tile_instructions.tiling.scaled_dimensions();
// librsvg does not currently support larger images
//
// TODO: We are only creating tiles, maybe this check in librsvg is wrong?
if total_height > 32_767 || total_width > 32_767 {
continue;
}
renderer
.render_document(
&context,
&cairo::Rectangle::new(
-area.x() as f64,
-area.y() as f64,
total_width as f64,
total_height as f64,
),
)
.context("Failed to render image")?;
drop(context);
let decoded_image = Decoded { surface };
let tile = tiling::Tile {
position: (
tile_instructions.area.x() as u32,
tile_instructions.area.y() as u32,
),
zoom_level: tiling::zoom_to_level(tile_instructions.tiling.zoom),
bleed: 2,
texture: decoded_image.into_texture(),
};
tiles.push(tile.clone());
}
}
}
}
Ok(())
});
Self {
thread_handle,
current_request,
}
}
pub fn request(&self, request: TileRequest, _abstract_decoder: &Decoder) -> anyhow::Result<()> {
let mut current_request = self.current_request.write().unwrap();
*current_request = Request::Tile(request);
self.thread_handle.thread().unpark();
Ok(())
}
}
pub fn svg_dimensions(renderer: &rsvg::CairoRenderer) -> (u32, u32) {
if let Some((width, height)) = renderer.intrinsic_size_in_pixels() {
(width.ceil() as u32, height.ceil() as u32)
} else {
match renderer.intrinsic_dimensions() {
rsvg::IntrinsicDimensions {
width:
rsvg::Length {
length: width,
unit: rsvg::LengthUnit::Percent,
},
height:
rsvg::Length {
length: height,
unit: rsvg::LengthUnit::Percent,
},
vbox: Some(vbox),
} => (
(width * vbox.width()).ceil() as u32,
(height * vbox.height()).ceil() as u32,
),
dimensions => {
log::warn!("Failed to parse SVG dimensions: {dimensions:?}");
(300, 300)
}
}
}
}
struct Decoded {
surface: cairo::ImageSurface,
}
impl Decoded {
pub fn into_texture(self) -> gdk::Texture {
let memory_format = {
#[cfg(target_endian = "little")]
{
gdk::MemoryFormat::B8g8r8a8
}
#[cfg(target_endian = "big")]
{
gdk::MemoryFormat::A8r8g8b8
}
};
let width = self.surface.width();
let height = self.surface.height();
let stride = self.surface.stride() as usize;
let bytes = glib::Bytes::from_owned(self.surface.take_data().unwrap().to_vec());
gdk::MemoryTexture::new(width, height, memory_format, &bytes, stride).upcast()
}
}

158
src/decoder/mod.rs Normal file
View file

@ -0,0 +1,158 @@
///! Decodes several image formats
mod formats;
pub mod tiling;
use crate::image_metadata::ImageMetadata;
use formats::*;
use tiling::TilingStoreExt;
use anyhow::Context;
use arc_swap::ArcSwap;
use futures::channel::mpsc;
use gio::prelude::*;
use gtk::graphene;
use std::io::Read;
use std::sync::Arc;
#[derive(Clone, Copy, Debug)]
pub enum ImageFormat {
ImageRs(image_rs::ImageFormat),
// TODO: Add details about contained format
Heif,
Svg,
}
impl std::fmt::Display for ImageFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
match self {
Self::ImageRs(format) => write!(f, "{:?}", format),
Self::Heif => write!(f, "HEIF"),
Self::Svg => write!(f, "SVG"),
}
}
}
#[derive(Clone, Copy, Debug)]
/// Renderer requests new tiles
///
/// This happens for initial loading or when zoom level or viewing area changes.
pub struct TileRequest {
pub viewport: graphene::Rect,
pub zoom: f64,
}
#[derive(Debug, Clone)]
/// Signals update to the renderer
pub struct UpdateSender {
sender: mpsc::UnboundedSender<DecoderUpdate>,
}
#[derive(Debug)]
/// Signals for renderer
pub enum DecoderUpdate {
/// Dimensions of image in `TilingSore` available/updated
Dimensions,
/// Metadata available
Metadata(ImageMetadata),
/// Image format determined
Format(ImageFormat),
/// New image data available, redraw
Redraw,
/// And error occured during decoding
Error(anyhow::Error),
}
impl UpdateSender {
pub fn send(&self, update: DecoderUpdate) {
let result = self.sender.unbounded_send(update);
if let Err(err) = result {
log::error!("Failed to send update: {err}");
}
}
/// Send occuring errors to renderer
pub fn spawn_error_handled<F>(&self, f: F) -> std::thread::JoinHandle<()>
where
F: FnOnce() -> Result<(), anyhow::Error> + Send + 'static,
{
let update_sender = self.clone();
std::thread::spawn(move || {
if let Err(err) = f() {
update_sender.send(DecoderUpdate::Error(err));
}
})
}
}
#[derive(Debug)]
pub struct Decoder {
decoder: FormatDecoder,
}
#[derive(Debug)]
enum FormatDecoder {
ImageRsOther(ImageRsOther),
Svg(Svg),
Heif(Heif),
}
impl Decoder {
/// Get new image decoder
///
/// The textures will be stored in the passed `TilingStore`.
/// The renderer should listen to updates from the returned receiver.
pub fn new(
file: gio::File,
tiles: Arc<ArcSwap<tiling::TilingStore>>,
) -> anyhow::Result<(Self, mpsc::UnboundedReceiver<DecoderUpdate>)> {
let path = file.path().context("Need a file path")?;
let (sender, receiver) = mpsc::unbounded();
let update_sender = UpdateSender { sender };
tiles.set_update_sender(update_sender.clone());
update_sender.send(DecoderUpdate::Metadata(ImageMetadata::load(&path)));
let mut buf = Vec::new();
let file = std::fs::File::open(&path)?;
file.take(64).read_to_end(&mut buf)?;
let format = image_rs::guess_format(&buf);
let decoder = if let Ok(format) = format {
match format {
image_rs::ImageFormat::Avif => {
update_sender.send(DecoderUpdate::Format(ImageFormat::Heif));
FormatDecoder::Heif(Heif::new(path, update_sender, tiles))
}
format => {
update_sender.send(DecoderUpdate::Format(ImageFormat::ImageRs(format)));
FormatDecoder::ImageRsOther(ImageRsOther::new(path, update_sender, tiles))
}
}
}
// TODO: Use mime glib mime type detection
else if [Some("svg"), Some("svgz")].contains(&path.extension().and_then(|x| x.to_str())) {
update_sender.send(DecoderUpdate::Format(ImageFormat::Svg));
FormatDecoder::Svg(Svg::new(path, update_sender, tiles))
} else if [Some("heic"), Some("avif")].contains(&path.extension().and_then(|x| x.to_str()))
{
update_sender.send(DecoderUpdate::Format(ImageFormat::Heif));
FormatDecoder::Heif(Heif::new(path, update_sender, tiles))
} else {
return None.context("unknown image format");
};
Ok((Self { decoder }, receiver))
}
/// Request missing tiles
pub fn request(&self, tile_request: TileRequest) {
match &self.decoder {
FormatDecoder::Svg(svg) => svg.request(tile_request, self).unwrap(),
FormatDecoder::Heif(_) => {}
_ => {}
};
}
}

483
src/decoder/tiling.rs Normal file
View file

@ -0,0 +1,483 @@
///! Tiled renderer
///!
///! It is not always feasible or desiareble to store the complete decoded
///! image in the VRAM. [`TilingStore`] allows to compose the parts of
///! the image currently viewed, out of smaller [`Tiles`].
///!
///! This is especially important for SVGs where every the image has to
///! be re-genearted for each zoom level. It can also be used to allow
///! showing large JPEGs etc where the complete decoded image would not
///! even fit in the VRAM.
use super::{DecoderUpdate, UpdateSender};
use crate::deps::*;
use arc_swap::ArcSwap;
use gtk::prelude::*;
use std::collections::BTreeMap;
use std::sync::Arc;
const ZOOM_SIGNIFICANT_DIGETS: i32 = 6;
pub const TILE_SIZE: u16 = 4000;
pub type ZoomLevel = u32;
pub type Coordinate = u32;
pub type Coordinates = (u32, u32);
#[derive(Clone, Debug)]
/// Part of the image
pub struct Tile {
pub position: Coordinates,
pub zoom_level: ZoomLevel,
/// Border that gets cut away to avoid scaling artifacts at the sides
pub bleed: u8,
pub texture: gdk::Texture,
}
impl Tile {
pub fn area(&self) -> graphene::Rect {
let bleed = self.bleed as f32;
// The texture is rendered slightly larger such that bleed can be cut away
self.area_with_bleed().inset_r(bleed, bleed)
}
pub fn area_with_bleed(&self) -> graphene::Rect {
let width = self.texture.width() as f32;
let height = self.texture.height() as f32;
let bleed = self.bleed as f32;
graphene::Rect::new(
self.position.0 as f32 - bleed,
self.position.1 as f32 - bleed,
width,
height,
)
}
pub fn zoom(&self) -> f32 {
level_to_zoom(self.zoom_level) as f32
}
/// Add tile to snapshot
///
/// This is where parts of images actually land on the screen
pub fn add_to_snapshot(&self, snapshot: &gtk::Snapshot, options: &RenderOptions) {
let zoom = self.zoom();
let area = self.area().scale(1. / zoom, 1. / zoom);
let area_with_bleed = self.area_with_bleed().scale(1. / zoom, 1. / zoom);
// TODO: do not clip outer bounderies of the image
snapshot.push_clip(&area);
snapshot.append_color(&gdk::RGBA::new(0.118, 0.118, 0.118, 1.), &area);
snapshot.append_scaled_texture(&self.texture, options.scaling_filter, &area_with_bleed);
snapshot.pop();
}
fn coordinates(&self) -> Coordinates {
self.position
}
}
#[derive(Clone, Debug)]
/// Store tiles for image
pub struct TilingStore {
pub tiles: BTreeMap<ZoomLevel, BTreeMap<Coordinates, Tile>>,
/// Tile size without bleed
pub tile_size: u16,
/// Complete image size
pub original_dimensions: Option<Coordinates>,
pub update_sender: Option<UpdateSender>,
}
impl Default for TilingStore {
fn default() -> Self {
Self::new(TILE_SIZE)
}
}
#[derive(Debug, Clone)]
pub struct RenderOptions {
pub scaling_filter: gsk::ScalingFilter,
}
impl TilingStore {
pub fn new(tile_size: u16) -> Self {
TilingStore {
tiles: Default::default(),
tile_size,
original_dimensions: None,
update_sender: None,
}
}
/// Render tiles as image
///
/// This is what actually draws the image
pub fn add_to_snapshot(&self, snapshot: &gtk::Snapshot, zoom: f64, options: &RenderOptions) {
let tiles: Vec<_> = self
.iter_rendering_priority(zoom)
.flat_map(|(_, tiles)| tiles)
.collect();
log::trace!("Rendering {} tiles", tiles.len());
// reverse to put least fitting tiles in the background
for (_, tile) in tiles.iter().rev() {
tile.add_to_snapshot(snapshot, options);
}
}
pub fn push(&mut self, tile: Tile) {
let map = self.tiles.entry(tile.zoom_level).or_default();
map.insert(tile.coordinates(), tile);
if let Some(updater) = &self.update_sender {
updater.send(DecoderUpdate::Redraw);
}
}
pub fn set_original_dimensions(&mut self, dimensions: Coordinates) {
self.original_dimensions = Some(dimensions);
if let Some(updater) = &self.update_sender {
updater.send(DecoderUpdate::Dimensions);
}
}
pub fn set_update_sender(&mut self, sender: UpdateSender) {
self.update_sender = Some(sender);
}
pub fn contains(&self, zoom: f64, coordinates: Coordinates) -> bool {
let Some(tile_plane) = self.tiles.get(&zoom_to_level(zoom)) else { return false };
tile_plane.contains_key(&coordinates)
}
/// Tiles ordered by how good they are suited for the zoom level
pub fn iter_rendering_priority(
&self,
zoom: f64,
) -> impl Iterator<Item = (&ZoomLevel, &BTreeMap<Coordinates, Tile>)> {
let zoom_level = zoom_to_level(zoom);
self.tiles
// Tiles at zoom level and with better resolution
.range(zoom_level..)
// Afterwards try tiles with worse resolution
.chain(self.tiles.range(..zoom_level).rev())
}
/// Viewport within zoom coordinates
///
/// TODO: This is the most buggy function ever
pub fn cleanup(&mut self, zoom: f64, viewport: graphene::Rect) {
let Some(original_dimensions) = self.original_dimensions else { log::error!("Too-early cleanup: Original dimension not known"); return; };
let mut kept_tiles = Self::new(self.tile_size);
// Always work with 100% zoom level for tiles
let mut missing_tiles = vec![viewport.scale(1. / zoom as f32, 1. / zoom as f32)];
// Keep tiles in top layer
// TODO: Change the top layer if window size changes
if let Some(top_layer) = self.tiles.iter().next() {
for tile in top_layer.1.values() {
kept_tiles.push(tile.clone());
}
}
// Put all tiles in correct processing order
let stored_tiles = self.iter_rendering_priority(zoom);
for (zoom_level, tiles) in stored_tiles {
let tile_zoom = level_to_zoom(*zoom_level);
let mut next_missing_tiles = vec![];
// Tiling for this zoom level and viewport
let tiling = Tiling {
tile_size: self.tile_size,
original_dimensions,
zoom: tile_zoom,
bleed: 2,
};
for mut tile_instruction in tiling.relevant_tiles(&viewport) {
// scale to 100% reference
tile_instruction.area = tile_instruction
.area
.scale(1. / tile_zoom as f32, 1. / tile_zoom as f32);
// Determine if tile of this zoom level has overlap with a missing tile
let mut intersections = missing_tiles
.iter()
.filter(|missing| tile_instruction.area.is_intersected(missing))
.peekable();
if intersections.peek().is_some() {
if let Some(tile) = tiles.get(&tile_instruction.coordinates) {
// we have this tile and can provide it
kept_tiles.push(tile.clone());
} else {
for missing in missing_tiles.iter() {
if let Some(overlap) = missing.intersection(&tile_instruction.area) {
next_missing_tiles.push(overlap);
}
}
}
}
}
missing_tiles = next_missing_tiles;
}
kept_tiles.tiles.values().flat_map(|x| x.iter()).count();
self.tiles = kept_tiles.tiles;
}
}
#[derive(Debug, Clone, Copy)]
/// Abstract definition of a tiling
pub struct Tiling {
pub tile_size: u16,
pub original_dimensions: Coordinates,
pub zoom: f64,
pub bleed: u8,
}
impl Tiling {
pub fn scaled_dimensions(&self) -> Coordinates {
let (w, h) = self.original_dimensions;
(
(w as f64 * self.zoom) as Coordinate,
(h as f64 * self.zoom) as Coordinate,
)
}
/// Returns relevant tiles
///
/// This gives what tiles are needed for convering the `preload_area`.
/// Decoders use the [`TileInstructions`] to know what they have to render.
pub fn relevant_tiles(&self, preload_area: &graphene::Rect) -> Vec<TileInstructions> {
let tile_size = self.tile_size as f32;
let x0 = (preload_area.x() / tile_size).floor() * tile_size;
let x1 = ((preload_area.x() + preload_area.width()) / tile_size).ceil() * tile_size;
let y0 = (preload_area.y() / tile_size).floor() * tile_size;
let y1 = ((preload_area.y() + preload_area.height()) / tile_size).ceil() * tile_size;
let mut tiles = Vec::new();
for x in (x0 as u32..x1 as u32).step_by(self.tile_size.into()) {
for y in (y0 as u32..y1 as u32).step_by(self.tile_size.into()) {
let width = tile_size
+ f32::min(
0.,
self.scaled_dimensions().0 as f32 - (x as f32 + tile_size),
);
let height = tile_size
+ f32::min(
0.,
self.scaled_dimensions().1 as f32 - (y as f32 + tile_size),
);
let area = graphene::Rect::new(x as f32, y as f32, width, height);
let tile = TileInstructions {
tiling: *self,
area,
coordinates: (x, y),
};
tiles.push(tile);
}
}
tiles.sort_by_key(|tile| tile.area.center().distance(&preload_area.center()).0 as u32);
tiles
}
}
#[derive(Debug, Copy, Clone)]
/// Instruction for decoder to generate this tile
pub struct TileInstructions {
pub tiling: Tiling,
pub area: graphene::Rect,
pub coordinates: Coordinates,
}
impl TileInstructions {
pub fn area_with_bleed(&self) -> graphene::Rect {
let bleed = self.tiling.bleed as f32;
self.area.inset_r(-bleed, -bleed)
}
}
/// Integer zoom levels are used because they are hashable
/// and ignore tiny float calculation errors.
pub fn zoom_to_level(zoom: f64) -> u32 {
(zoom * 10_f64.powi(ZOOM_SIGNIFICANT_DIGETS)) as u32
}
pub fn level_to_zoom(zoom_level: u32) -> f64 {
zoom_level as f64 * 10_f64.powi(-ZOOM_SIGNIFICANT_DIGETS)
}
pub fn zoom_normalize(zoom: f64) -> f64 {
level_to_zoom(zoom_to_level(zoom))
}
trait RectExt {
fn is_intersected(&self, b: &graphene::Rect) -> bool;
}
impl RectExt for graphene::Rect {
fn is_intersected(&self, b: &graphene::Rect) -> bool {
self.intersection(b).is_some()
}
}
pub trait TilingStoreExt {
fn push(&self, tile: Tile);
fn set_original_dimensions(&self, size: Coordinates);
fn set_update_sender(&self, sender: UpdateSender);
}
impl TilingStoreExt for ArcSwap<TilingStore> {
fn push(&self, tile: Tile) {
self.rcu(|tiling_store| {
let mut new_store = (**tiling_store).clone();
new_store.push(tile.clone());
Arc::new(new_store)
});
}
fn set_original_dimensions(&self, size: Coordinates) {
self.rcu(|tiling_store| {
let mut new_store = (**tiling_store).clone();
new_store.set_original_dimensions(size);
Arc::new(new_store)
});
}
fn set_update_sender(&self, sender: UpdateSender) {
self.rcu(|tiling_store| {
let mut new_store = (**tiling_store).clone();
new_store.set_update_sender(sender.clone());
Arc::new(new_store)
});
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_tiling_cleanup() {
let mut store = TilingStore::new(2);
let toplevel = Tile {
position: (0, 0),
zoom_level: zoom_to_level(1.),
bleed: 0,
texture: gdk::MemoryTexture::new(
4,
4,
gdk::MemoryFormat::R8g8b8,
&glib::Bytes::from_static(&[0; 4 * 4 * 3]),
4 * 3,
)
.upcast(),
};
store.push(toplevel);
let right_level = Tile {
position: (0, 0),
zoom_level: zoom_to_level(1.5),
bleed: 0,
texture: gdk::MemoryTexture::new(
2,
2,
gdk::MemoryFormat::R8g8b8,
&glib::Bytes::from_static(&[0; 2 * 2 * 3]),
2 * 3,
)
.upcast(),
};
store.push(right_level);
let low_level = Tile {
position: (2, 0),
zoom_level: zoom_to_level(1.6),
bleed: 0,
texture: gdk::MemoryTexture::new(
2,
2,
gdk::MemoryFormat::R8g8b8,
&glib::Bytes::from_static(&[0; 2 * 2 * 3]),
2 * 3,
)
.upcast(),
};
store.push(low_level);
let overlapped = Tile {
position: (0, 0),
zoom_level: zoom_to_level(1.7),
bleed: 0,
texture: gdk::MemoryTexture::new(
2,
2,
gdk::MemoryFormat::R8g8b8,
&glib::Bytes::from_static(&[0; 2 * 2 * 3]),
2 * 3,
)
.upcast(),
};
store.push(overlapped);
store.cleanup(1.5, graphene::Rect::new(0., 0., 4. * 1.5, 4. * 1.5));
assert!(store.tiles.get(&1000000).unwrap().get(&(0, 0)).is_some());
assert!(store.tiles.get(&1500000).unwrap().get(&(0, 0)).is_some());
assert!(store.tiles.get(&1600000).unwrap().get(&(2, 0)).is_some());
// TODO: This should pass, but it does not
//assert!(store.tiles.get(&1700000).is_none());
}
#[test]
fn test_tiling_cleanup_simple() {
let mut store = TilingStore::new(2);
let toplevel = Tile {
position: (0, 0),
zoom_level: zoom_to_level(1.),
bleed: 0,
texture: gdk::MemoryTexture::new(
4,
4,
gdk::MemoryFormat::R8g8b8,
&glib::Bytes::from_static(&[0; 4 * 4 * 3]),
4 * 3,
)
.upcast(),
};
store.push(toplevel);
let detailled = Tile {
position: (2, 2),
zoom_level: zoom_to_level(2.),
bleed: 0,
texture: gdk::MemoryTexture::new(
4,
4,
gdk::MemoryFormat::R8g8b8,
&glib::Bytes::from_static(&[0; 4 * 4 * 3]),
4 * 3,
)
.upcast(),
};
store.push(detailled);
store.cleanup(1.5, graphene::Rect::new(0., 0., 20., 20.));
assert!(store.tiles.get(&1000000).unwrap().get(&(0, 0)).is_some());
assert!(store.tiles.get(&2000000).unwrap().get(&(2, 2)).is_some());
}
}

View file

@ -22,6 +22,7 @@ use gtk::gio::{self, prelude::*};
mod application; mod application;
mod config; mod config;
mod decoder;
mod file_model; mod file_model;
mod i18n; mod i18n;
mod image_metadata; mod image_metadata;
@ -31,7 +32,7 @@ mod widgets;
mod window; mod window;
mod deps { mod deps {
pub use gtk::{gdk, gdk_pixbuf, gio, glib, graphene, gsk}; pub use gtk::{cairo, gdk, gdk_pixbuf, gio, glib, graphene, gsk};
} }
use application::LpApplication; use application::LpApplication;

View file

@ -67,4 +67,17 @@ cargo_doc = custom_target(
cargo, 'doc', cargo, 'doc',
cargo_options + ['--no-deps', '--document-private-items'], cargo_options + ['--no-deps', '--document-private-items'],
], ],
) )
test(
'cargo-test',
cargo,
args: [
'test',
cargo_options,
'--',
'--test-threads=1',
],
is_parallel: false,
timeout: 1800,
)

View file

@ -17,18 +17,22 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
use crate::deps::*; use crate::deps::*;
use crate::decoder::{self, tiling, Decoder, DecoderUpdate};
use crate::i18n::i18n; use crate::i18n::i18n;
use crate::image_metadata::{ImageMetadata, LpImageMetadata}; use crate::image_metadata::LpImageMetadata;
use crate::util; use crate::util;
use adw::{prelude::*, subclass::prelude::*}; use adw::{prelude::*, subclass::prelude::*};
use anyhow::Context; use arc_swap::ArcSwap;
use futures::prelude::*;
use gtk_macros::spawn; use gtk_macros::spawn;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use once_cell::unsync::OnceCell; use once_cell::unsync::OnceCell;
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Milliseconds /// Milliseconds
const ZOOM_ANIMATION_DURATION: u32 = 200; const ZOOM_ANIMATION_DURATION: u32 = 200;
@ -65,9 +69,13 @@ mod imp {
pub is_deleted: Cell<bool>, pub is_deleted: Cell<bool>,
/// Track changes to this image /// Track changes to this image
pub file_monitor: RefCell<Option<gio::FileMonitor>>, pub file_monitor: RefCell<Option<gio::FileMonitor>>,
pub texture: RefCell<Option<gdk::Texture>>, pub tiles: Arc<ArcSwap<tiling::TilingStore>>,
/// Set when image is completely loaded and ready for displaying pub decoder: RefCell<Option<Arc<Decoder>>>,
/// Set to true when image is ready for displaying
pub is_loaded: Cell<bool>, pub is_loaded: Cell<bool>,
/// Set if an error has occurred, shown on error_page
pub error: RefCell<Option<String>>,
/// Rotation final value (can differ from `rotation` during animation) /// Rotation final value (can differ from `rotation` during animation)
pub rotation_target: Cell<f64>, pub rotation_target: Cell<f64>,
@ -101,8 +109,6 @@ mod imp {
/// Currently EXIF data /// Currently EXIF data
pub image_metadata: RefCell<LpImageMetadata>, pub image_metadata: RefCell<LpImageMetadata>,
/// This will often be available earlier than the complete image
pub image_original_size: Cell<(i32, i32)>,
/// Current pointer position /// Current pointer position
pub pointer_position: Cell<Option<(f64, f64)>>, pub pointer_position: Cell<Option<(f64, f64)>>,
@ -142,6 +148,7 @@ mod imp {
glib::ParamSpecBoolean::builder("is-loaded") glib::ParamSpecBoolean::builder("is-loaded")
.read_only() .read_only()
.build(), .build(),
glib::ParamSpecBoolean::builder("error").read_only().build(),
glib::ParamSpecObject::builder::<LpImageMetadata>("metadata") glib::ParamSpecObject::builder::<LpImageMetadata>("metadata")
.read_only() .read_only()
.build(), .build(),
@ -180,6 +187,7 @@ mod imp {
"path" => obj.path().to_variant().to_value(), "path" => obj.path().to_variant().to_value(),
"is-deleted" => obj.is_deleted().to_value(), "is-deleted" => obj.is_deleted().to_value(),
"is-loaded" => obj.is_loaded().to_value(), "is-loaded" => obj.is_loaded().to_value(),
"error" => obj.error().to_value(),
"metadata" => obj.metadata().to_value(), "metadata" => obj.metadata().to_value(),
"rotation" => obj.rotation().to_value(), "rotation" => obj.rotation().to_value(),
"mirrored" => obj.mirrored().to_value(), "mirrored" => obj.mirrored().to_value(),
@ -228,6 +236,8 @@ mod imp {
fn dispose(&self) { fn dispose(&self) {
let obj = self.obj(); let obj = self.obj();
log::debug!("Disposing LpImage");
// remove target from zoom animation because it's property of this object // remove target from zoom animation because it's property of this object
obj.rotation_animation() obj.rotation_animation()
.set_target(&adw::CallbackAnimationTarget::new(|_| {})); .set_target(&adw::CallbackAnimationTarget::new(|_| {}));
@ -455,13 +465,8 @@ mod imp {
// ensure there is an actual size change // ensure there is an actual size change
if self.widget_dimensions.get() != (width, height) { if self.widget_dimensions.get() != (width, height) {
self.widget_dimensions.set((width, height)); self.widget_dimensions.set((width, height));
// calculate new zoom value for best fit
if widget.is_best_fit() { widget.configure_best_fit();
let best_fit_level = widget.zoom_level_best_fit();
self.zoom.set(best_fit_level);
self.zoom_target.set(best_fit_level);
self.obj().zoom_animation().pause();
}
} }
widget.configure_adjustments(); widget.configure_adjustments();
@ -469,67 +474,88 @@ mod imp {
// called when the widget content should be re-rendered // called when the widget content should be re-rendered
fn snapshot(&self, snapshot: &gtk::Snapshot) { fn snapshot(&self, snapshot: &gtk::Snapshot) {
if let Some(texture) = self.texture.borrow().as_ref() { let widget = self.obj();
let widget = self.obj(); let widget_width = widget.width() as f64;
let widget_width = widget.width() as f64; let widget_height = widget.height() as f64;
let widget_height = widget.height() as f64; let (original_width, original_height) = widget.original_dimensions();
let display_width = widget.image_displayed_width();
let display_height = widget.image_displayed_height();
// make sure the scrollbars are correct // make sure the scrollbars are correct
widget.configure_adjustments(); widget.configure_adjustments();
snapshot.save(); let applicable_zoom = widget.applicable_zoom();
// apply the scrolling position to the image let scaling_filter = gsk::ScalingFilter::Linear;
if let Some(adj) = self.hadjustment.borrow().as_ref() { /*
let x = -(adj.value() - (adj.upper() - widget_width) / 2.); let scaling_filter = if applicable_zoom < 1. {
snapshot.translate(&graphene::Point::new(x as f32, 0.)); gsk::ScalingFilter::Trilinear
} } else {
if let Some(adj) = self.vadjustment.borrow().as_ref() { gsk::ScalingFilter::Nearest
let y = -(adj.value() - (adj.upper() - widget_height) / 2.); };
snapshot.translate(&graphene::Point::new(0., y as f32)); */
}
// Center image in coordinates. let render_options = tiling::RenderOptions { scaling_filter };
// Needed for rotating around the center of the image, and
// mirroring the image does not put it to a completely different position.
snapshot.translate(&graphene::Point::new(
(widget_width / 2.) as f32,
(widget_height / 2.) as f32,
));
// vertical centering in widget when no scrolling // Operations on snapshots are coordinate transformations
let x = f64::max((widget_width - widget.image_displayed_width()) / 2.0, 0.); // It might help to read the following code from bottom to top
let y = f64::max((widget_height - widget.image_displayed_height()) / 2.0, 0.); snapshot.save();
// round to pixel values to not have a half pixel offset to physical pixels
// the offset would leading to a blurry output
snapshot.translate(&graphene::Point::new(
widget.round(x) as f32,
widget.round(y) as f32,
));
// apply the transformations from properties // Apply the scrolling position to the image
snapshot.rotate(widget.rotation() as f32); if let Some(adj) = self.hadjustment.borrow().as_ref() {
if widget.mirrored() { let x = -(adj.value() - (adj.upper() - display_width) / 2.);
snapshot.scale(-1., 1.); snapshot.translate(&graphene::Point::new(x as f32, 0.));
}
snapshot.scale(widget.zoom() as f32, widget.zoom() as f32);
// scale to actual pixel size
// this is needed since usually the texture would just fill the widget
snapshot.scale(
(texture.width() as f64 / widget_width) as f32,
(texture.height() as f64 / widget_height) as f32,
);
// move back to original position
snapshot.translate(&graphene::Point::new(
(-widget_width / 2.) as f32,
(-widget_height / 2.) as f32,
));
texture.snapshot(snapshot, widget_width, widget_height);
snapshot.restore();
} }
if let Some(adj) = self.vadjustment.borrow().as_ref() {
let y = -(adj.value() - (adj.upper() - display_height) / 2.);
snapshot.translate(&graphene::Point::new(0., y as f32));
}
// Centering in widget when no scrolling (black bars around image)
let x = f64::max((widget_width - display_width) / 2.0, 0.).floor();
let y = f64::max((widget_height - display_height) / 2.0, 0.).floor();
// Round to pixel values to not have a half pixel offset to physical pixels
// The offset would leading to a blurry output
snapshot.translate(&graphene::Point::new(
widget.round(x) as f32,
widget.round(y) as f32,
));
// Apply zoom
snapshot.scale(applicable_zoom as f32, applicable_zoom as f32);
// Put image origin at (0, 0) again with rotation
snapshot.translate(&graphene::Point::new(
-(original_width as f32 - display_width as f32 / applicable_zoom as f32) / 2.,
-(original_height as f32 - display_height as f32 / applicable_zoom as f32) / 2.,
));
// Undo centering in coordinates
snapshot.translate(&graphene::Point::new(
original_width as f32 / 2.,
original_height as f32 / 2.,
));
// Apply the transformations from properties
snapshot.rotate(widget.rotation() as f32);
if widget.mirrored() {
snapshot.scale(-1., 1.);
}
// Center image in coordinates.
// Needed for rotating around the center of the image, and
// mirroring the image does not put it to a completely different position.
snapshot.translate(&graphene::Point::new(
-original_width as f32 / 2.,
-original_height as f32 / 2.,
));
// Add texture(s)
self.tiles
.load()
.add_to_snapshot(snapshot, applicable_zoom, &render_options);
snapshot.restore();
} }
fn measure(&self, orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) { fn measure(&self, orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) {
@ -619,65 +645,83 @@ glib::wrapper! {
} }
impl LpImage { impl LpImage {
pub async fn load(&self, path: &Path) -> anyhow::Result<()> { pub async fn load(&self, path: &Path) {
let imp = self.imp();
let path = path.to_path_buf(); let path = path.to_path_buf();
let file = gio::File::for_path(&path); let file = gio::File::for_path(&path);
self.set_file(&file); self.set_file(&file);
if !path.is_file() { if !path.is_file() {
return Err(anyhow::Error::msg(i18n("File does not exist"))); self.set_error(anyhow::Error::msg(i18n("File does not exist")));
return;
} }
log::debug!("Loading file {path:?}"); log::debug!("Loading file {path:?}");
log::debug!("Loading EXIF"); let tiles = &self.imp().tiles;
let metadata = util::spawn( // TODO: Fix two unwraps
"image-load-exif", let (decoder, mut decoder_update) = util::spawn(
glib::clone!(@strong path => move || ImageMetadata::load(&path)), "image-init-decoder",
glib::clone!(@strong file, @strong tiles => move || {
Decoder::new(file.clone(), tiles.clone())
}),
) )
.await?; .await
let orientation = metadata.orientation(); .unwrap()
.unwrap();
imp.image_metadata.replace(LpImageMetadata::from(metadata)); let weak_obj = self.downgrade();
imp.rotation_target.set(-orientation.rotation); spawn!(async move {
imp.rotation.set(-orientation.rotation); while let Some(update) = decoder_update.next().await {
imp.mirrored.set(orientation.mirrored); if let Some(obj) = weak_obj.upgrade() {
obj.update(update);
}
}
log::debug!("Stopped listening to decoder since sender is gone");
});
self.notify("metadata"); let decoder = Arc::new(decoder);
self.imp().decoder.replace(Some(decoder));
}
log::debug!("Loading image size"); /// Called when decoder sends update
let (_, x, y) = gdk_pixbuf::Pixbuf::file_info_future(path.clone()) pub fn update(&self, update: DecoderUpdate) {
.await? let imp = self.imp();
.context(i18n("Failed to load image size"))?;
imp.image_original_size.set((x, y));
self.notify("image-size");
log::debug!("Loading complete image"); match update {
let texture = util::spawn( DecoderUpdate::Metadata(metadata) => {
"image-load-image", log::debug!("Received metadata");
glib::clone!(@weak file => @default-return Err(anyhow::Error::msg(i18n("File does not exist"))), move || { imp.image_metadata.replace(LpImageMetadata::from(metadata));
log::debug!("Creating texture"); self.notify("metadata");
let texture = gdk::Texture::from_file(&file)?;
Ok::<_,anyhow::Error>(texture)
}),
)
.await?
.context(i18n("Failed to load image"))?;
imp.texture.replace(Some(texture)); self.reset_rotation();
}
DecoderUpdate::Dimensions => {
log::debug!("Received dimensions: {:?}", self.original_dimensions());
self.notify("image-size");
self.configure_best_fit();
self.request_tiles();
}
DecoderUpdate::Redraw => {
if !self.is_loaded() {
self.imp().is_loaded.set(true);
self.notify("is-loaded");
}
self.configure_adjustments(); self.queue_draw();
imp.tiles.rcu(|tiles| {
self.queue_draw(); let mut new_tiles = (**tiles).clone();
self.queue_allocate(); // TODO: Use an area larger than the viewport
self.queue_resize(); new_tiles.cleanup(self.imp().zoom_target.get(), self.viewport());
new_tiles
log::debug!("Image loaded and scheduled for drawing"); });
imp.is_loaded.set(true); }
self.notify("is-loaded"); DecoderUpdate::Error(err) => {
self.set_error(err);
Ok(()) }
DecoderUpdate::Format(_) => {
// TODO: Store and use in image properties
}
}
} }
pub fn is_loaded(&self) -> bool { pub fn is_loaded(&self) -> bool {
@ -700,26 +744,34 @@ impl LpImage {
/// ///
/// Used for calculating the required zoom level after rotation /// Used for calculating the required zoom level after rotation
fn zoom_level_best_fit_for_rotation(&self, rotation: f64) -> f64 { fn zoom_level_best_fit_for_rotation(&self, rotation: f64) -> f64 {
if let Some(texture) = self.texture() { let rotated = rotation.to_radians().sin().abs();
let rotated = rotation.to_radians().sin().abs(); let (image_width, image_height) = self.original_dimensions();
let texture_aspect_ratio = texture.width() as f64 / texture.height() as f64; let texture_aspect_ratio = image_width as f64 / image_height as f64;
let widget_aspect_ratio = self.width() as f64 / self.height() as f64; let widget_aspect_ratio = self.width() as f64 / self.height() as f64;
let default_zoom = if texture_aspect_ratio > widget_aspect_ratio { let default_zoom = if texture_aspect_ratio > widget_aspect_ratio {
(self.width() as f64 / texture.width() as f64).min(1.) (self.width() as f64 / image_width as f64).min(1.)
} else {
(self.height() as f64 / texture.height() as f64).min(1.)
};
let rotated_zoom = if 1. / texture_aspect_ratio > widget_aspect_ratio {
(self.width() as f64 / texture.height() as f64).min(1.)
} else {
(self.height() as f64 / texture.width() as f64).min(1.)
};
rotated * rotated_zoom + (1. - rotated) * default_zoom
} else { } else {
1. (self.height() as f64 / image_height as f64).min(1.)
};
let rotated_zoom = if 1. / texture_aspect_ratio > widget_aspect_ratio {
(self.width() as f64 / image_height as f64).min(1.)
} else {
(self.height() as f64 / image_width as f64).min(1.)
};
rotated * rotated_zoom + (1. - rotated) * default_zoom
}
/// Sets respective output values if best-fit is active
fn configure_best_fit(&self) {
// calculate new zoom value for best fit
if self.is_best_fit() {
let best_fit_level = self.zoom_level_best_fit();
self.imp().zoom.set(best_fit_level);
self.set_zoom_target(best_fit_level);
self.zoom_animation().pause();
} }
} }
@ -768,7 +820,7 @@ impl LpImage {
let obj = self.clone(); let obj = self.clone();
let file = file_a.clone(); let file = file_a.clone();
// TODO: error handling is missing // TODO: error handling is missing
spawn! {async move { let _ = obj.load(&file.path().unwrap()).await; }}; spawn! {async move { obj.load(&file.path().unwrap()).await; }};
} }
gio::FileMonitorEvent::Deleted gio::FileMonitorEvent::Deleted
| gio::FileMonitorEvent::MovedOut | gio::FileMonitorEvent::MovedOut
@ -782,8 +834,7 @@ impl LpImage {
/// Texture that contains the original image /// Texture that contains the original image
pub fn texture(&self) -> Option<gdk::Texture> { pub fn texture(&self) -> Option<gdk::Texture> {
let imp = self.imp(); None
imp.texture.borrow().clone()
} }
fn mirrored(&self) -> bool { fn mirrored(&self) -> bool {
@ -814,6 +865,14 @@ impl LpImage {
self.queue_draw(); self.queue_draw();
} }
/// Set rotation and mirroring to the state would have after loading
pub fn reset_rotation(&self) {
let orientation = self.metadata().orientation();
self.imp().rotation_target.set(-orientation.rotation);
self.set_mirrored(orientation.mirrored);
self.set_rotation(-orientation.rotation);
}
pub fn rotate_by(&self, angle: f64) { pub fn rotate_by(&self, angle: f64) {
log::debug!("Rotate by {} degrees", angle); log::debug!("Rotate by {} degrees", angle);
let target = &self.imp().rotation_target; let target = &self.imp().rotation_target;
@ -883,6 +942,10 @@ impl LpImage {
self.notify("is-max-zoom"); self.notify("is-max-zoom");
} }
fn applicable_zoom(&self) -> f64 {
decoder::tiling::zoom_normalize(self.zoom())
}
/// Set zoom level aiming for given position or center if not available /// Set zoom level aiming for given position or center if not available
fn set_zoom_aiming(&self, mut zoom: f64, aiming: Option<(f64, f64)>) { fn set_zoom_aiming(&self, mut zoom: f64, aiming: Option<(f64, f64)>) {
// allow some deviation from max value for rubberbanding // allow some deviation from max value for rubberbanding
@ -903,7 +966,6 @@ impl LpImage {
minimum * deviation.powf(RUBBERBANDING_EXPONENT), minimum * deviation.powf(RUBBERBANDING_EXPONENT),
max_deviation, max_deviation,
); );
dbg!(zoom);
} }
if zoom == self.zoom() { if zoom == self.zoom() {
@ -953,6 +1015,36 @@ impl LpImage {
self.queue_draw(); self.queue_draw();
} }
fn set_zoom_target(&self, zoom_target: f64) {
log::debug!("Setting zoom target {zoom_target}");
self.imp().zoom_target.set(zoom_target);
if self.zoom() == self.imp().zoom_target.get() {
self.request_tiles();
}
}
fn request_tiles(&self) {
if let Some(decoder) = self.imp().decoder.borrow().as_ref() {
if self.zoom_animation().state() != adw::AnimationState::Playing {
decoder.request(crate::decoder::TileRequest {
viewport: self.viewport(),
zoom: self.imp().zoom_target.get(),
});
}
}
}
fn viewport(&self) -> graphene::Rect {
let x = self.hadjustment().value();
let y = self.vadjustment().value();
let width = self.widget_width();
let height = self.widget_height();
graphene::Rect::new((x) as f32, (y) as f32, (width) as f32, (height) as f32)
}
/// Animation that makes larger zoom steps (from buttons etc) look smooth /// Animation that makes larger zoom steps (from buttons etc) look smooth
fn zoom_animation(&self) -> &adw::TimedAnimation { fn zoom_animation(&self) -> &adw::TimedAnimation {
self.imp().zoom_animation.get_or_init(|| { self.imp().zoom_animation.get_or_init(|| {
@ -965,6 +1057,7 @@ impl LpImage {
animation.connect_done(glib::clone!(@weak self as obj => move |_| { animation.connect_done(glib::clone!(@weak self as obj => move |_| {
obj.imp().zoom_hscrollbar_transition.set(false); obj.imp().zoom_hscrollbar_transition.set(false);
obj.imp().zoom_vscrollbar_transition.set(false); obj.imp().zoom_vscrollbar_transition.set(false);
obj.set_zoom_target(obj.imp().zoom_target.get());
})); }));
animation animation
@ -1050,7 +1143,7 @@ impl LpImage {
log::debug!("Zoom to {zoom:.3}"); log::debug!("Zoom to {zoom:.3}");
self.imp().zoom_target.set(zoom); self.set_zoom_target(zoom);
// abort if already at correct zoom level // abort if already at correct zoom level
if zoom == self.zoom() { if zoom == self.zoom() {
@ -1058,39 +1151,44 @@ impl LpImage {
return; return;
} }
if let Some(texture) = self.texture() { // wild code
let current_hborder = self.widget_width() - self.image_displayed_width(); let current_hborder = self.widget_width() - self.image_displayed_width();
let target_hborder = self.widget_width() - texture.width() as f64 * zoom; let target_hborder = self.widget_width() - self.image_size().0 as f64 * zoom;
self.imp() self.imp()
.zoom_hscrollbar_transition .zoom_hscrollbar_transition
.set(current_hborder.signum() != target_hborder.signum() && current_hborder != 0.); .set(current_hborder.signum() != target_hborder.signum() && current_hborder != 0.);
let current_vborder = self.widget_height() - self.image_displayed_height(); let current_vborder = self.widget_height() - self.image_displayed_height();
let target_vborder = self.widget_height() - texture.height() as f64 * zoom; let target_vborder = self.widget_height() - self.image_size().1 as f64 * zoom;
self.imp() self.imp()
.zoom_hscrollbar_transition .zoom_hscrollbar_transition
.set(current_vborder.signum() != target_vborder.signum() && current_vborder != 0.); .set(current_vborder.signum() != target_vborder.signum() && current_vborder != 0.);
let animation = self.zoom_animation(); let animation = self.zoom_animation();
animation.set_value_from(self.zoom()); animation.set_value_from(self.zoom());
animation.set_value_to(zoom); animation.set_value_to(zoom);
animation.play(); animation.play();
} else {
log::error!("No texture to zoom");
}
} }
/// Image size of original image with EXIF rotation applied /// Image size of original image with EXIF rotation applied
pub fn image_size(&self) -> (i32, i32) { pub fn image_size(&self) -> (i32, i32) {
let orientation = self.imp().image_metadata.borrow().orientation(); let orientation = self.imp().image_metadata.borrow().orientation();
if orientation.rotation.abs() == 90. { if orientation.rotation.abs() == 90. || orientation.rotation.abs() == 270. {
let (x, y) = self.imp().image_original_size.get(); let (x, y) = self.original_dimensions();
(y, x) (y, x)
} else { } else {
self.imp().image_original_size.get() self.original_dimensions()
}
}
fn original_dimensions(&self) -> (i32, i32) {
if let Some((width, height)) = self.imp().tiles.load().original_dimensions {
(width as i32, height as i32)
} else {
(0, 0)
} }
} }
@ -1100,25 +1198,19 @@ impl LpImage {
/// represent the actual size. The size returned might well be /// represent the actual size. The size returned might well be
/// larger than what can actually be displayed within the widget. /// larger than what can actually be displayed within the widget.
pub fn image_displayed_width(&self) -> f64 { pub fn image_displayed_width(&self) -> f64 {
if let Some(texture) = self.texture() { let (width, height) = self.original_dimensions();
let rotated = self.rotation().to_radians().sin().abs();
((1. - rotated) * texture.width() as f64 + rotated * texture.height() as f64) let rotated = self.rotation().to_radians().sin().abs();
* self.zoom()
} else { ((1. - rotated) * width as f64 + rotated * height as f64) * self.applicable_zoom()
0.
}
} }
pub fn image_displayed_height(&self) -> f64 { pub fn image_displayed_height(&self) -> f64 {
if let Some(texture) = self.texture() { let (width, height) = self.original_dimensions();
let rotated = self.rotation().to_radians().sin().abs();
((1. - rotated) * texture.height() as f64 + rotated * texture.width() as f64) let rotated = self.rotation().to_radians().sin().abs();
* self.zoom()
} else { ((1. - rotated) * height as f64 + rotated * width as f64) * self.applicable_zoom()
0.
}
} }
/// Stepwise scrolls inside an image when zoomed in /// Stepwise scrolls inside an image when zoomed in
@ -1162,10 +1254,9 @@ impl LpImage {
fn set_hadjustment(&self, adjustment: Option<gtk::Adjustment>) { fn set_hadjustment(&self, adjustment: Option<gtk::Adjustment>) {
if let Some(adj) = &adjustment { if let Some(adj) = &adjustment {
adj.connect_value_changed(glib::clone!(@weak self as obj => move |_| { adj.connect_value_changed(glib::clone!(@weak self as obj => move |_| {
obj.request_tiles();
obj.queue_draw(); obj.queue_draw();
})); }));
// TODO: needed?
self.queue_allocate();
} }
self.imp().hadjustment.replace(adjustment); self.imp().hadjustment.replace(adjustment);
@ -1184,9 +1275,9 @@ impl LpImage {
fn set_vadjustment(&self, adjustment: Option<gtk::Adjustment>) { fn set_vadjustment(&self, adjustment: Option<gtk::Adjustment>) {
if let Some(adj) = &adjustment { if let Some(adj) = &adjustment {
adj.connect_value_changed(glib::clone!(@weak self as obj => move |_| { adj.connect_value_changed(glib::clone!(@weak self as obj => move |_| {
obj.request_tiles();
obj.queue_draw(); obj.queue_draw();
})); }));
self.queue_allocate();
} }
self.imp().vadjustment.replace(adjustment); self.imp().vadjustment.replace(adjustment);
@ -1201,6 +1292,7 @@ impl LpImage {
let widget_width = self.widget_width(); let widget_width = self.widget_width();
hadjustment.configure( hadjustment.configure(
// value
hadjustment.value().clamp(0., self.max_hadjustment_value()), hadjustment.value().clamp(0., self.max_hadjustment_value()),
// lower // lower
0., 0.,
@ -1269,6 +1361,17 @@ impl LpImage {
Some(gdk::ContentProvider::for_value(&list.to_value())) Some(gdk::ContentProvider::for_value(&list.to_value()))
} }
/// Returns decoding error if one occured
pub fn error(&self) -> Option<String> {
self.imp().error.borrow().clone()
}
fn set_error(&self, err: anyhow::Error) {
log::debug!("Decoding error: {err:?}");
self.imp().error.replace(Some(err.to_string()));
self.notify("error");
}
/// Returns scaling aware rounded application pixel /// Returns scaling aware rounded application pixel
/// ///
/// One physical pixel is 0.5 application pixels /// One physical pixel is 0.5 application pixels

View file

@ -27,7 +27,6 @@ use gtk::CompositeTemplate;
use gtk_macros::spawn; use gtk_macros::spawn;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use std::cell::RefCell;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
mod imp { mod imp {
@ -52,9 +51,6 @@ mod imp {
pub(super) right_click_gesture: TemplateChild<gtk::GestureClick>, pub(super) right_click_gesture: TemplateChild<gtk::GestureClick>,
#[template_child] #[template_child]
pub(super) press_gesture: TemplateChild<gtk::GestureLongPress>, pub(super) press_gesture: TemplateChild<gtk::GestureLongPress>,
/// set if an error has occurred and is shown on error_page
pub(super) error: RefCell<Option<String>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -75,12 +71,9 @@ mod imp {
impl ObjectImpl for LpImagePage { impl ObjectImpl for LpImagePage {
fn properties() -> &'static [glib::ParamSpec] { fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![ vec![glib::ParamSpecObject::builder::<LpImage>("image")
glib::ParamSpecObject::builder::<LpImage>("image") .read_only()
.read_only() .build()]
.build(),
glib::ParamSpecString::builder("error").read_only().build(),
]
}); });
PROPERTIES.as_ref() PROPERTIES.as_ref()
@ -90,7 +83,6 @@ mod imp {
let obj = self.obj(); let obj = self.obj();
match pspec.name() { match pspec.name() {
"image" => obj.image().to_value(), "image" => obj.image().to_value(),
"error" => obj.error().to_value(),
name => unimplemented!("property {name}"), name => unimplemented!("property {name}"),
} }
} }
@ -112,6 +104,30 @@ mod imp {
obj.show_popover_at(x, y); obj.show_popover_at(x, y);
gesture.set_state(gtk::EventSequenceState::Claimed); gesture.set_state(gtk::EventSequenceState::Claimed);
})); }));
obj.image().connect_notify_local(
Some("is-loaded"),
clone!(@weak obj => move |_,_| {
if obj.image().is_loaded() {
obj.imp()
.stack
.set_visible_child(&*obj.imp().scrolled_window);
}
}),
);
obj.image().connect_notify_local(
Some("error"),
clone!(@weak obj => move |_,_| {
if obj.image().error().is_some() {
obj.imp().stack.set_visible_child(&*obj.imp().error_page);
}
}),
);
// Do not waste CPU on spinner if it is not visible
self.spinner.connect_map(|s| s.start());
self.spinner.connect_unmap(|s| s.stop());
} }
} }
@ -139,19 +155,7 @@ impl LpImagePage {
spawn!(clone!(@weak obj, @strong path => async move { spawn!(clone!(@weak obj, @strong path => async move {
let imp = obj.imp(); let imp = obj.imp();
match imp.image.load(&path).await { imp.image.load(&path).await;
Ok(()) => {
imp.stack.set_visible_child(&*imp.scrolled_window);
imp.spinner.set_spinning(false);
},
Err(e) => {
imp.error.replace(Some(e.to_string()));
imp.stack.set_visible_child(&*imp.error_page);
obj.notify("error");
imp.spinner.set_spinning(false);
log::error!("Could not load image: {e}");
}
}
})); }));
obj obj
@ -169,10 +173,6 @@ impl LpImagePage {
self.imp().image.texture() self.imp().image.texture()
} }
pub fn error(&self) -> Option<String> {
self.imp().error.borrow().clone()
}
pub fn content_provider(&self) -> Option<gdk::ContentProvider> { pub fn content_provider(&self) -> Option<gdk::ContentProvider> {
self.imp().image.content_provider() self.imp().image.content_provider()
} }

View file

@ -509,7 +509,7 @@ impl LpImageView {
other_pages.remove(&new_page.path()); other_pages.remove(&new_page.path());
for (_, page) in other_pages { for (_, page) in other_pages {
let image = page.image(); let image = page.image();
image.set_rotation(0.); image.reset_rotation();
image.set_best_fit(true); image.set_best_fit(true);
image.zoom_best_fit(); image.zoom_best_fit();
} }

View file

@ -163,6 +163,7 @@ mod imp {
let transform = gsk::Transform::new().translate(&graphene::Point::new(x, 0.)); let transform = gsk::Transform::new().translate(&graphene::Point::new(x, 0.));
page.allocate(width, height, 0, Some(transform)); page.allocate(width, height, 0, Some(transform));
page.image().allocate(width, height, 0, None);
} }
} }

View file

@ -33,9 +33,6 @@ use crate::util::{self, Direction, Position};
use crate::widgets::{LpImage, LpImageView, LpPropertiesView}; use crate::widgets::{LpImage, LpImageView, LpPropertiesView};
mod imp { mod imp {
use crate::widgets::LpImagePage;
use once_cell::sync::OnceCell;
use super::*; use super::*;
// To use composite templates, you need // To use composite templates, you need
@ -78,8 +75,6 @@ mod imp {
pub properties_view: TemplateChild<LpPropertiesView>, pub properties_view: TemplateChild<LpPropertiesView>,
#[template_child] #[template_child]
pub drop_target: TemplateChild<gtk::DropTarget>, pub drop_target: TemplateChild<gtk::DropTarget>,
current_page_signals: OnceCell<glib::SignalGroup>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -270,24 +265,20 @@ mod imp {
}), }),
); );
let current_page_signals = glib::SignalGroup::new(LpImagePage::static_type()); current_image_signals.connect_closure(
self.image_view.connect_notify_local(
Some("current-page"),
glib::clone!(@weak obj, @weak current_page_signals => move |view, _| {
current_page_signals.set_target(view.current_page().as_ref());
obj.images_available();
}),
);
current_page_signals.connect_closure(
"notify::error", "notify::error",
false, false,
glib::closure_local!(@watch win => move |_: &LpImagePage, _: &glib::ParamSpec| { glib::closure_local!(@watch win => move |_: &LpImage, _: &glib::ParamSpec| {
win.image_error(); win.image_error();
}), }),
); );
self.current_page_signals.set(current_page_signals).unwrap(); self.image_view.connect_notify_local(
Some("current-page"),
glib::clone!(@weak obj => move |_, _| {
obj.images_available();
}),
);
// action win.previous status // action win.previous status
self.image_view.connect_notify_local( self.image_view.connect_notify_local(
@ -647,7 +638,7 @@ impl LpWindow {
let current_page = self.imp().image_view.current_page(); let current_page = self.imp().image_view.current_page();
if let Some(page) = current_page { if let Some(page) = current_page {
if page.error().is_some() { if page.image().error().is_some() {
log::debug!("Showing window because loading image failed"); log::debug!("Showing window because loading image failed");
self.present(); self.present();
} }