Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/freya-components/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ ureq = { workspace = true, optional = true }
# Image viewer
blocking = { workspace = true }
bytes = { version = "1.10.1" }
euclid = { workspace = true }

# Other
cfg-if = "1.0"
Expand Down
2 changes: 1 addition & 1 deletion crates/freya-components/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use freya_core::{

/// Defines the duration for which an Asset will remain cached after it's user has stopped using it.
/// The default is 1h (3600s).
#[derive(Hash, PartialEq, Eq, Clone)]
#[derive(Hash, PartialEq, Eq, Clone, Copy)]
pub enum AssetAge {
/// Asset will be cached for the specified duration
Duration(Duration),
Expand Down
2 changes: 1 addition & 1 deletion crates/freya-components/src/gif_viewer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ enum Status {

impl Component for GifViewer {
fn render(&self) -> impl IntoElement {
let asset_config = AssetConfiguration::new(&self.source, self.asset_age.clone());
let asset_config = AssetConfiguration::new(&self.source, self.asset_age);
let asset_data = use_asset(&asset_config);
let mut status = use_state(|| Status::Decoding);
let mut cached_frames = use_state::<Option<Rc<CachedGifFrames>>>(|| None);
Expand Down
113 changes: 98 additions & 15 deletions crates/freya-components/src/image_viewer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,18 @@ use freya_core::{
prelude::*,
};
use freya_engine::prelude::{
FilterMode,
MipmapMode,
Paint,
SamplingOptions,
SkData,
SkImage,
SkRect,
raster_n32_premul,
};
use torin::prelude::{
Size,
Size2D,
};
#[cfg(feature = "remote-asset")]
use ureq::http::Uri;
Expand Down Expand Up @@ -101,17 +111,13 @@ impl<H: Hash> From<(H, Bytes)> for ImageSource {

impl<H: Hash> From<(H, &'static [u8])> for ImageSource {
fn from((id, bytes): (H, &'static [u8])) -> Self {
let mut hasher = DefaultHasher::default();
id.hash(&mut hasher);
Self::Bytes(hasher.finish(), Bytes::from_static(bytes))
(id, Bytes::from_static(bytes)).into()
}
}

impl<const N: usize, H: Hash> From<(H, &'static [u8; N])> for ImageSource {
fn from((id, bytes): (H, &'static [u8; N])) -> Self {
let mut hasher = DefaultHasher::default();
id.hash(&mut hasher);
Self::Bytes(hasher.finish(), Bytes::from_static(bytes))
(id, Bytes::from_static(bytes)).into()
}
}

Expand Down Expand Up @@ -148,8 +154,10 @@ impl Hash for ImageSource {
}
}

pub type DecodeSize = euclid::Size2D<u32, ()>;

impl ImageSource {
pub async fn bytes(&self) -> anyhow::Result<(SkImage, Bytes)> {
pub async fn bytes(&self, decode_size: Option<DecodeSize>) -> anyhow::Result<(SkImage, Bytes)> {
let source = self.clone();
blocking::unblock(move || {
let bytes = match source {
Expand All @@ -162,13 +170,78 @@ impl ImageSource {
Self::Path(path) => fs::read(path).map(Bytes::from)?,
Self::Bytes(_, bytes) => bytes,
};
let image = SkImage::from_encoded(unsafe { SkData::new_bytes(&bytes) })
let encoded = SkImage::from_encoded(unsafe { SkData::new_bytes(&bytes) })
.context("Failed to decode Image.")?;
let image = image.make_raster_image(None, None).unwrap_or(image);
let image = match decode_size.and_then(|t| Self::downsample(&encoded, t)) {
Some(scaled) => scaled,
None => encoded.make_raster_image(None, None).unwrap_or(encoded),
};
Ok((image, bytes))
})
.await
}

fn downsample(encoded: &SkImage, target: DecodeSize) -> Option<SkImage> {
let natural_width = encoded.width() as f32;
let natural_height = encoded.height() as f32;
let target_width = target.width as f32;
let target_height = target.height as f32;
if natural_width <= target_width && natural_height <= target_height {
return None;
}
let ratio = (target_width / natural_width).min(target_height / natural_height);
let width = (natural_width * ratio).round().max(1.);
let height = (natural_height * ratio).round().max(1.);

let mut surface = raster_n32_premul((width as i32, height as i32))?;
let destination = SkRect::from_xywh(0., 0., width, height);
let sampling = SamplingOptions::new(FilterMode::Linear, MipmapMode::Linear);
let mut paint = Paint::default();
paint.set_anti_alias(true);
surface.canvas().draw_image_rect_with_sampling_options(
encoded,
None,
destination,
sampling,
&paint,
);
Some(surface.image_snapshot())
}
}

/// How an [`ImageViewer`] picks its decode dimensions.
#[derive(Default, Clone, Debug, PartialEq, Copy)]
pub enum DecodeMode {
/// Default. Layout size scaled by the window scale factor, falling back to natural size when the layout isn't pixel-bound.
#[default]
FromLayout,
/// Decode at the image's natural size.
Source,
/// Decode at this exact fit-within size.
Custom(Size2D),
}

impl DecodeMode {
fn resolve(&self, layout: &LayoutData, scale_factor: f64) -> Option<DecodeSize> {
let scale = scale_factor as f32;
let size = match self {
Self::Source => return None,
Self::FromLayout => match (&layout.width, &layout.height) {
(Size::Pixels(width), Size::Pixels(height)) => {
Size2D::new(width.get() * scale, height.get() * scale)
}
_ => {
tracing::debug!("DecodeMode::FromLayout decoded at natural size.");
return None;
}
},
Self::Custom(size) => *size,
};
Some(DecodeSize::new(
size.width.round().max(1.) as u32,
size.height.round().max(1.) as u32,
))
}
}

/// Image viewer component.
Expand Down Expand Up @@ -212,6 +285,7 @@ pub struct ImageViewer {
accessibility: AccessibilityData,
effect: EffectData,
corner_radius: Option<CornerRadius>,
decode_mode: DecodeMode,

children: Vec<Element>,
loading_placeholder: Option<Element>,
Expand All @@ -230,6 +304,7 @@ impl ImageViewer {
accessibility: AccessibilityData::default(),
effect: EffectData::default(),
corner_radius: None,
decode_mode: DecodeMode::default(),
children: Vec::new(),
loading_placeholder: None,
error_renderer: None,
Expand Down Expand Up @@ -289,6 +364,12 @@ impl ImageViewer {
self
}

/// Pick how the image is decoded. See [`DecodeMode`].
pub fn decode_mode(mut self, decode_mode: DecodeMode) -> Self {
self.decode_mode = decode_mode;
self
}

/// Customize how long the image will remain cached after no longer being used.
///
/// Defaults to [`AssetAge::default`] (1h).
Expand All @@ -306,15 +387,16 @@ impl ImageViewer {

impl Component for ImageViewer {
fn render(&self) -> impl IntoElement {
let asset_config = AssetConfiguration::new(&self.source, self.asset_age.clone());
let target = self
.decode_mode
.resolve(&self.layout, *Platform::get().scale_factor.read());
let asset_config = AssetConfiguration::new((&self.source, target), self.asset_age);
let asset = use_asset(&asset_config);
let mut asset_cacher = use_hook(AssetCacher::get);

use_side_effect_with_deps(
&(self.source.clone(), asset_config),
move |(source, asset_config): &(ImageSource, AssetConfiguration)| {
// Fetch asset if still pending or errored. The Loading state
// guards against duplicate in-flight fetches.
&(self.source.clone(), asset_config, target),
move |(source, asset_config, target)| {
if matches!(
asset_cacher.read_asset(asset_config),
Some(Asset::Pending) | Some(Asset::Error(_))
Expand All @@ -323,8 +405,9 @@ impl Component for ImageViewer {

let source = source.clone();
let asset_config = asset_config.clone();
let target = *target;
spawn_forever(async move {
match source.bytes().await {
match source.bytes(target).await {
Ok((image, bytes)) => {
// Image loaded
let image_holder = ImageHolder {
Expand Down
2 changes: 2 additions & 0 deletions crates/freya-core/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ pub struct Platform {
pub focused_accessibility_node: State<accesskit::Node>,
/// The size of the root window.
pub root_size: State<Size2D>,
/// OS scale factor.
pub scale_factor: State<f64>,
/// The current [`NavigationMode`].
pub navigation_mode: State<NavigationMode>,
/// The OS-level [`PreferredTheme`].
Expand Down
1 change: 1 addition & 0 deletions crates/freya-testing/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ impl TestingRunner {
accesskit::Role::Window,
)),
root_size: State::create(size),
scale_factor: State::create(scale_factor),
navigation_mode: State::create(NavigationMode::NotKeyboard),
preferred_theme: State::create(PreferredTheme::Light),
is_app_focused: State::create(true),
Expand Down
1 change: 1 addition & 0 deletions crates/freya-winit/src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,7 @@ impl ApplicationHandler<NativeEvent> for WinitRenderer {
});
}
WindowEvent::ScaleFactorChanged { .. } => {
app.sync_scale_factor();
app.window.request_redraw();
app.process_layout_on_next_render = true;
app.tree.layout.reset();
Expand Down
11 changes: 11 additions & 0 deletions crates/freya-winit/src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ impl AppWindow {
_ => PreferredTheme::Light,
};
let is_app_focused = window.has_focus();
let scale_factor = window.scale_factor();
move || Platform {
focused_accessibility_id: State::create(ACCESSIBILITY_ROOT_ID),
focused_accessibility_node: State::create(accesskit::Node::new(
Expand All @@ -211,6 +212,7 @@ impl AppWindow {
window_size.width as f32,
window_size.height as f32,
)),
scale_factor: State::create(scale_factor),
navigation_mode: State::create(NavigationMode::NotKeyboard),
preferred_theme: State::create(theme),
is_app_focused: State::create(is_app_focused),
Expand Down Expand Up @@ -385,6 +387,14 @@ impl AppWindow {
self.window.scale_factor() * self.user_zoom as f64
}

/// Republishes the effective scale factor on [`Platform`] so consumers
/// (e.g. `ImageViewer`) pick it up on their next render.
pub fn sync_scale_factor(&mut self) {
self.platform
.scale_factor
.set(self.effective_scale_factor());
}

/// Sets `user_zoom`, clamped to `[MIN_USER_ZOOM, MAX_USER_ZOOM]`. On change,
/// resets layout/text caches and requests a redraw, mirroring `ScaleFactorChanged`.
pub fn set_user_zoom(&mut self, zoom: f32) {
Expand All @@ -393,6 +403,7 @@ impl AppWindow {
return;
}
self.user_zoom = clamped;
self.sync_scale_factor();
self.process_layout_on_next_render = true;
self.tree.layout.reset();
self.tree.text_cache.reset();
Expand Down
50 changes: 50 additions & 0 deletions examples/component_image_viewer_decode_size.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]

use freya::prelude::*;

fn main() {
launch(LaunchConfig::new().with_window(WindowConfig::new(app)))
}

const SOURCE: &str = "https://images.pexels.com/photos/842711/pexels-photo-842711.jpeg";

const PRESETS: [(&str, DecodeMode); 4] = [
("FromLayout (default)", DecodeMode::FromLayout),
("Source", DecodeMode::Source),
("Custom 64×64", DecodeMode::Custom(Size2D::new(64., 64.))),
(
"Custom 256×256",
DecodeMode::Custom(Size2D::new(256., 256.)),
),
];

fn app() -> impl IntoElement {
let mut preset = use_state(|| 0);
let (label, mode) = PRESETS[preset()];

rect()
.expanded()
.center()
.spacing(16.)
.child(format!("Decode mode: {label}"))
.child(
ImageViewer::new(SOURCE)
.width(Size::px(600.))
.height(Size::px(400.))
.decode_mode(mode),
)
.child(
rect()
.horizontal()
.spacing(12.)
.children(PRESETS.iter().enumerate().map(|(i, (label, _))| {
Button::new()
.on_press(move |_| *preset.write() = i)
.child(*label)
.into()
})),
)
}
Loading