feat: added placeholder image for images that couldnt be decoded; fixed overflowing titles in mods view (#802)

fix: drop per-render image fallback logging and narrow its memoization cost

fix: keep the backdrop fallback, the cache is what fails
This commit is contained in:
Maciek 2026-09-13 00:54:10 +02:00 • committed by GitHub
parent ba74bedf92
commit 576c93c96b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 71 additions and 32 deletions

View file

@ -8,6 +8,7 @@ use oneclient_common::{VersionKey, parse_mc_version};
use crate::AppAssets;
use crate::hooks::{loaded_image, use_cached_image, use_version_metadata};
use crate::layout::HOME_BACKGROUND_ASSET;
use crate::ui::ImageFallbackExt;
/// The size cluster cards request so it is usually already cached when anything else wants it
pub const ART_PREVIEW_EDGE: u32 = 512;
@ -121,6 +122,7 @@ impl Component for DynamicArt {
.height(Size::fill())
.aspect_ratio(AspectRatio::Max)
.image_cover(ImageCover::Center)
.fallback(rect().width(Size::fill()).height(Size::fill()))
.into_element()
}
}

View file

@ -7,7 +7,7 @@ use crate::components::{Icon, IconType, toggle_controlled};
use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation};
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::ui::{ImageFallbackExt, border_all_color};
use crate::utils::format_size;
pub(crate) const CARD_BG: Color = Color::from_rgb(26, 34, 41);
@ -268,6 +268,7 @@ pub(crate) fn grid_card(
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.width(Size::fill())
.color(CARD_NAME.with_a(content_alpha)),
)
@ -277,6 +278,7 @@ pub(crate) fn grid_card(
.text(format!("by {}", item.author))
.font_size(10.)
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.width(Size::fill())
.color(colors::fg_secondary().with_a(content_alpha)),
)
@ -368,6 +370,7 @@ fn package_info(
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.child(
@ -376,6 +379,8 @@ fn package_info(
.font_size(15.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.max_width(Size::percent(60.))
.color(CARD_NAME),
)
.child(if remote {
@ -391,6 +396,9 @@ fn package_info(
label()
.text(format!("by {}", item.author))
.font_size(10.)
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.width(Size::fill())
.color(colors::fg_secondary()),
)
})
@ -400,6 +408,7 @@ fn package_info(
.text(item.description.clone())
.font_size(11.)
.max_lines(2)
.text_overflow(TextOverflow::Ellipsis)
.width(Size::fill())
.color(colors::fg_secondary()),
)
@ -422,6 +431,7 @@ pub(crate) fn package_icon(
.height(Size::px(size))
.aspect_ratio(AspectRatio::Min)
.corner_radius(CornerRadius::new_all(8.))
.fallback(icon_box(IconType::DotsGrid, size))
.into_element(),
None if icon_url.is_some() => icon_box(IconType::DotsGrid, size),

View file

@ -172,3 +172,14 @@ pub fn entrance_motion_layer(
)
.into_element()
}
pub trait ImageFallbackExt: Sized {
fn fallback(self, placeholder: impl IntoElement) -> Self;
}
impl ImageFallbackExt for ImageViewer {
fn fallback(self, placeholder: impl IntoElement) -> Self {
let placeholder = placeholder.into_element();
self.error_renderer(move |_: String| placeholder.clone())
}
}

View file

@ -7,7 +7,7 @@ use crate::components::{
};
use crate::hooks::{loaded_image, use_cached_image};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::ui::{ImageFallbackExt, border_all_color};
use crate::utils::{format_duration_hm, plural};
use super::{card, card_header};
@ -371,6 +371,7 @@ impl Component for ServerIcon {
.height(Size::px(size))
.aspect_ratio(AspectRatio::Min)
.corner_radius(CornerRadius::new_all(6.))
.fallback(server_icon_placeholder(self.is_ip, size))
.into_element(),
None => server_icon_placeholder(self.is_ip, size),
}

View file

@ -13,7 +13,7 @@ use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}
use crate::components::{Icon, IconType};
use crate::hooks::{loaded_image, use_cached_image};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::ui::{ImageFallbackExt, border_all_color};
const BANNER_BG: Color = Color::from_rgb(21, 28, 34);
@ -251,29 +251,36 @@ impl Component for Thumbnail {
let query = use_cached_image(self.icon_url.clone(), 256);
let loaded = loaded_image(self.icon_url.as_deref(), &query);
let placeholder = thumbnail_placeholder(size, radius, 0.4);
match loaded {
Some((url, bytes)) => ImageViewer::new((url, bytes))
.width(Size::px(size))
.height(Size::px(size))
.aspect_ratio(AspectRatio::Min)
.corner_radius(CornerRadius::new_all(radius))
.fallback(placeholder)
.into_element(),
None => rect()
.center()
.width(Size::px(size))
.height(Size::px(size))
.corner_radius(CornerRadius::new_all(radius))
.background(colors::component_bg())
.child(
Icon::new(IconType::DotsGrid)
.size(size * 0.4)
.color(colors::fg_secondary()),
)
.into_element(),
None => placeholder,
}
}
}
fn thumbnail_placeholder(size: f32, radius: f32, icon_ratio: f32) -> Element {
rect()
.center()
.width(Size::px(size))
.height(Size::px(size))
.corner_radius(CornerRadius::new_all(radius))
.background(colors::component_bg())
.child(
Icon::new(IconType::DotsGrid)
.size(size * icon_ratio)
.color(colors::fg_secondary()),
)
.into_element()
}
#[derive(PartialEq)]
pub(crate) struct PackageBanner {
icon_url: Option<String>,
@ -311,6 +318,8 @@ impl Component for PackageBanner {
.overflow(Overflow::Clip)
.background(BANNER_BG);
let icon_placeholder = thumbnail_placeholder(icon, 10., 0.45);
match loaded {
Some((url, bytes)) => banner
.child(
@ -324,7 +333,8 @@ impl Component for PackageBanner {
.width(Size::fill())
.height(Size::fill())
.aspect_ratio(AspectRatio::Max)
.image_cover(ImageCover::Center),
.image_cover(ImageCover::Center)
.fallback(rect().width(Size::fill()).height(Size::fill())),
)
.layer(Layer::Relative(1)),
)
@ -347,23 +357,12 @@ impl Component for PackageBanner {
.width(Size::px(icon))
.height(Size::px(icon))
.aspect_ratio(AspectRatio::Min)
.corner_radius(CornerRadius::new_all(10.)),
.corner_radius(CornerRadius::new_all(10.))
.fallback(icon_placeholder),
)
.layer(Layer::Relative(5)),
),
None => banner.child(
rect()
.center()
.width(Size::px(icon))
.height(Size::px(icon))
.corner_radius(CornerRadius::new_all(10.))
.background(colors::component_bg())
.child(
Icon::new(IconType::DotsGrid)
.size(icon * 0.45)
.color(colors::fg_secondary()),
),
),
None => banner.child(icon_placeholder),
}
}
}

View file

@ -9,7 +9,7 @@ use crate::components::{
};
use crate::hooks::{loaded_image, query_is_busy, use_cached_image, use_view_state};
use crate::theme::colors;
use crate::ui::{border_all_color, flow_grid};
use crate::ui::{ImageFallbackExt, border_all_color, flow_grid};
const MAX_COL_W: f32 = 400.;
const GRID_GAP: f32 = 16.;
@ -118,6 +118,7 @@ impl Component for GalleryTile {
.height(Size::fill())
.aspect_ratio(AspectRatio::Max)
.image_cover(ImageCover::Center)
.fallback(broken_image(32.))
.into_element()
}));
@ -202,6 +203,7 @@ impl Component for GalleryViewer {
.width(Size::fill())
.height(Size::fill())
.aspect_ratio(AspectRatio::Min)
.fallback(broken_image(48.))
.into_element()
}))
// A full-size fetch separate from the tile's cached copy a failed fetch is not loading
@ -265,6 +267,19 @@ impl Component for GalleryViewer {
}
}
fn broken_image(size: f32) -> Element {
rect()
.center()
.width(Size::fill())
.height(Size::fill())
.child(
Icon::new(IconType::FileX02)
.size(size)
.color(colors::fg_secondary()),
)
.into_element()
}
fn chevron_btn(
icon: IconType,
enabled: bool,

View file

@ -4,7 +4,7 @@ use oneclient_content::packages::ProviderId;
use crate::components::{Icon, provider_badge};
use crate::hooks::{loaded_image, use_cached_image};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::ui::{ImageFallbackExt, border_all_color};
use crate::utils::format_size;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
@ -36,6 +36,7 @@ impl Component for OnboardingModCard {
.height(Size::px(44.))
.aspect_ratio(AspectRatio::Min)
.corner_radius(CornerRadius::new_all(8.))
.fallback(icon_box(self.provider))
.into_element(),
None => icon_box(self.provider).into_element(),