Add image dimension and file size information (#21675)

Closes https://github.com/zed-industries/zed/issues/21281

@jansol, kindly take a look when you're free.


![image](https://github.com/user-attachments/assets/da9a54fa-6284-4012-a243-7e355a5290d3)

Release Notes:

- Added dimensions and file size information for images.

---------

Co-authored-by: tims <0xtimsb@gmail.com>
Co-authored-by: Marshall Bowers <git@maxdeviant.com>
This commit is contained in:
Caleb! 2025-02-07 01:56:34 +01:00 committed by GitHub
parent a1ed1a00b3
commit d6d0d7d3e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 312 additions and 10 deletions

View file

@ -13,7 +13,7 @@ path = "src/image_viewer.rs"
doctest = false
[features]
test-support = ["gpui/test-support"]
test-support = ["gpui/test-support", "editor/test-support"]
[dependencies]
anyhow.workspace = true
@ -22,6 +22,8 @@ editor.workspace = true
file_icons.workspace = true
gpui.workspace = true
project.workspace = true
schemars.workspace = true
serde.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true

View file

@ -0,0 +1,124 @@
use gpui::{div, Context, Entity, IntoElement, ParentElement, Render, Subscription};
use project::image_store::{ImageFormat, ImageMetadata};
use settings::Settings;
use ui::prelude::*;
use workspace::{ItemHandle, StatusItemView, Workspace};
use crate::{ImageFileSizeUnit, ImageView, ImageViewerSettings};
pub struct ImageInfo {
metadata: Option<ImageMetadata>,
_observe_active_image: Option<Subscription>,
observe_image_item: Option<Subscription>,
}
impl ImageInfo {
pub fn new(_workspace: &Workspace) -> Self {
Self {
metadata: None,
_observe_active_image: None,
observe_image_item: None,
}
}
fn update_metadata(&mut self, image_view: &Entity<ImageView>, cx: &mut Context<Self>) {
let image_item = image_view.read(cx).image_item.clone();
let current_metadata = image_item.read(cx).image_metadata;
if current_metadata.is_some() {
self.metadata = current_metadata;
cx.notify();
} else {
self.observe_image_item = Some(cx.observe(&image_item, |this, item, cx| {
this.metadata = item.read(cx).image_metadata;
cx.notify();
}));
}
}
}
fn format_file_size(size: u64, image_unit_type: ImageFileSizeUnit) -> String {
match image_unit_type {
ImageFileSizeUnit::Binary => {
if size < 1024 {
format!("{size}B")
} else if size < 1024 * 1024 {
format!("{:.1}KiB", size as f64 / 1024.0)
} else {
format!("{:.1}MiB", size as f64 / (1024.0 * 1024.0))
}
}
ImageFileSizeUnit::Decimal => {
if size < 1000 {
format!("{size}B")
} else if size < 1000 * 1000 {
format!("{:.1}KB", size as f64 / 1000.0)
} else {
format!("{:.1}MB", size as f64 / (1000.0 * 1000.0))
}
}
}
}
impl Render for ImageInfo {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let settings = ImageViewerSettings::get_global(cx);
let Some(metadata) = self.metadata.as_ref() else {
return div();
};
let mut components = Vec::new();
components.push(format!("{}x{}", metadata.width, metadata.height));
components.push(format_file_size(metadata.file_size, settings.unit));
if let Some(colors) = metadata.colors {
components.push(format!(
"{} channels, {} bits per pixel",
colors.channels,
colors.bits_per_pixel()
));
}
components.push(
match metadata.format {
ImageFormat::Png => "PNG",
ImageFormat::Jpeg => "JPEG",
ImageFormat::Gif => "GIF",
ImageFormat::WebP => "WebP",
ImageFormat::Tiff => "TIFF",
ImageFormat::Bmp => "BMP",
ImageFormat::Ico => "ICO",
ImageFormat::Avif => "Avif",
_ => "Unknown",
}
.to_string(),
);
div().child(
Button::new("image-metadata", components.join("")).label_size(LabelSize::Small),
)
}
}
impl StatusItemView for ImageInfo {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self._observe_active_image = None;
self.observe_image_item = None;
if let Some(image_view) = active_pane_item.and_then(|item| item.act_as::<ImageView>(cx)) {
self.update_metadata(&image_view, cx);
self._observe_active_image = Some(cx.observe(&image_view, |this, view, cx| {
this.update_metadata(&view, cx);
}));
} else {
self.metadata = None;
}
cx.notify();
}
}

View file

@ -1,3 +1,6 @@
mod image_info;
mod image_viewer_settings;
use std::path::PathBuf;
use anyhow::Context as _;
@ -19,7 +22,8 @@ use workspace::{
ItemId, ItemSettings, ToolbarItemLocation, Workspace, WorkspaceId,
};
const IMAGE_VIEWER_KIND: &str = "ImageView";
pub use crate::image_info::*;
pub use crate::image_viewer_settings::*;
pub struct ImageView {
image_item: Entity<ImageItem>,
@ -31,7 +35,6 @@ impl ImageView {
pub fn new(
image_item: Entity<ImageItem>,
project: Entity<Project>,
cx: &mut Context<Self>,
) -> Self {
cx.subscribe(&image_item, Self::on_image_event).detach();
@ -49,7 +52,9 @@ impl ImageView {
cx: &mut Context<Self>,
) {
match event {
ImageItemEvent::FileHandleChanged | ImageItemEvent::Reloaded => {
ImageItemEvent::MetadataUpdated
| ImageItemEvent::FileHandleChanged
| ImageItemEvent::Reloaded => {
cx.emit(ImageViewEvent::TitleChanged);
cx.notify();
}
@ -188,7 +193,7 @@ fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) ->
impl SerializableItem for ImageView {
fn serialized_item_kind() -> &'static str {
IMAGE_VIEWER_KIND
"ImageView"
}
fn deserialize(
@ -357,8 +362,9 @@ impl ProjectItem for ImageView {
}
pub fn init(cx: &mut App) {
ImageViewerSettings::register(cx);
workspace::register_project_item::<ImageView>(cx);
workspace::register_serializable_item::<ImageView>(cx)
workspace::register_serializable_item::<ImageView>(cx);
}
mod persistence {

View file

@ -0,0 +1,42 @@
use gpui::App;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources};
/// The settings for the image viewer.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default)]
pub struct ImageViewerSettings {
/// The unit to use for displaying image file sizes.
///
/// Default: "binary"
#[serde(default)]
pub unit: ImageFileSizeUnit,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum ImageFileSizeUnit {
/// Displays file size in binary units (e.g., KiB, MiB).
#[default]
Binary,
/// Displays file size in decimal units (e.g., KB, MB).
Decimal,
}
impl Settings for ImageViewerSettings {
const KEY: Option<&'static str> = Some("image_viewer");
type FileContent = Self;
fn load(
sources: SettingsSources<Self::FileContent>,
_: &mut App,
) -> Result<Self, anyhow::Error> {
SettingsSources::<Self::FileContent>::json_merge_with(
[sources.default]
.into_iter()
.chain(sources.user)
.chain(sources.server),
)
}
}

View file

@ -2,12 +2,15 @@ use crate::{
worktree_store::{WorktreeStore, WorktreeStoreEvent},
Project, ProjectEntryId, ProjectItem, ProjectPath,
};
use anyhow::{Context as _, Result};
use anyhow::{anyhow, Context as _, Result};
use collections::{hash_map, HashMap, HashSet};
use futures::{channel::oneshot, StreamExt};
use gpui::{
hash, prelude::*, App, Context, Entity, EventEmitter, Img, Subscription, Task, WeakEntity,
hash, prelude::*, App, AsyncApp, Context, Entity, EventEmitter, Img, Subscription, Task,
WeakEntity,
};
pub use image::ImageFormat;
use image::{ExtendedColorType, GenericImageView, ImageReader};
use language::{DiskState, File};
use rpc::{AnyProtoClient, ErrorExt as _};
use std::ffi::OsStr;
@ -32,10 +35,12 @@ impl From<NonZeroU64> for ImageId {
}
}
#[derive(Debug)]
pub enum ImageItemEvent {
ReloadNeeded,
Reloaded,
FileHandleChanged,
MetadataUpdated,
}
impl EventEmitter<ImageItemEvent> for ImageItem {}
@ -46,14 +51,106 @@ pub enum ImageStoreEvent {
impl EventEmitter<ImageStoreEvent> for ImageStore {}
#[derive(Debug, Clone, Copy)]
pub struct ImageMetadata {
pub width: u32,
pub height: u32,
pub file_size: u64,
pub colors: Option<ImageColorInfo>,
pub format: ImageFormat,
}
#[derive(Debug, Clone, Copy)]
pub struct ImageColorInfo {
pub channels: u8,
pub bits_per_channel: u8,
}
impl ImageColorInfo {
pub fn from_color_type(color_type: impl Into<ExtendedColorType>) -> Option<Self> {
let (channels, bits_per_channel) = match color_type.into() {
ExtendedColorType::L8 => (1, 8),
ExtendedColorType::L16 => (1, 16),
ExtendedColorType::La8 => (2, 8),
ExtendedColorType::La16 => (2, 16),
ExtendedColorType::Rgb8 => (3, 8),
ExtendedColorType::Rgb16 => (3, 16),
ExtendedColorType::Rgba8 => (4, 8),
ExtendedColorType::Rgba16 => (4, 16),
ExtendedColorType::A8 => (1, 8),
ExtendedColorType::Bgr8 => (3, 8),
ExtendedColorType::Bgra8 => (4, 8),
ExtendedColorType::Cmyk8 => (4, 8),
_ => return None,
};
Some(Self {
channels,
bits_per_channel,
})
}
pub const fn bits_per_pixel(&self) -> u8 {
self.channels * self.bits_per_channel
}
}
pub struct ImageItem {
pub id: ImageId,
pub file: Arc<dyn File>,
pub image: Arc<gpui::Image>,
reload_task: Option<Task<()>>,
pub image_metadata: Option<ImageMetadata>,
}
impl ImageItem {
pub async fn load_image_metadata(
image: Entity<ImageItem>,
project: Entity<Project>,
cx: &mut AsyncApp,
) -> Result<ImageMetadata> {
let (fs, image_path) = cx.update(|cx| {
let project_path = image.read(cx).project_path(cx);
let worktree = project
.read(cx)
.worktree_for_id(project_path.worktree_id, cx)
.ok_or_else(|| anyhow!("worktree not found"))?;
let worktree_root = worktree.read(cx).abs_path();
let image_path = image.read(cx).path();
let image_path = if image_path.is_absolute() {
image_path.to_path_buf()
} else {
worktree_root.join(image_path)
};
let fs = project.read(cx).fs().clone();
anyhow::Ok((fs, image_path))
})??;
let image_bytes = fs.load_bytes(&image_path).await?;
let image_format = image::guess_format(&image_bytes)?;
let mut image_reader = ImageReader::new(std::io::Cursor::new(image_bytes));
image_reader.set_format(image_format);
let image = image_reader.decode()?;
let (width, height) = image.dimensions();
let file_metadata = fs
.metadata(image_path.as_path())
.await?
.ok_or_else(|| anyhow!("failed to load image metadata"))?;
Ok(ImageMetadata {
width,
height,
file_size: file_metadata.len,
format: image_format,
colors: ImageColorInfo::from_color_type(image.color()),
})
}
pub fn project_path(&self, cx: &App) -> ProjectPath {
ProjectPath {
worktree_id: self.file.worktree_id(cx),
@ -391,6 +488,7 @@ impl ImageStoreImpl for Entity<LocalImageStore> {
id: cx.entity_id().as_non_zero_u64().into(),
file: file.clone(),
image,
image_metadata: None,
reload_task: None,
})?;

View file

@ -2075,8 +2075,25 @@ impl Project {
return Task::ready(Err(anyhow!(ErrorCode::Disconnected)));
}
self.image_store.update(cx, |image_store, cx| {
let open_image_task = self.image_store.update(cx, |image_store, cx| {
image_store.open_image(path.into(), cx)
});
let weak_project = cx.entity().downgrade();
cx.spawn(move |_, mut cx| async move {
let image_item = open_image_task.await?;
let project = weak_project
.upgrade()
.ok_or_else(|| anyhow!("Project dropped"))?;
let metadata =
ImageItem::load_image_metadata(image_item.clone(), project, &mut cx).await?;
image_item.update(&mut cx, |image_item, cx| {
image_item.image_metadata = Some(metadata);
cx.emit(ImageItemEvent::MetadataUpdated);
})?;
Ok(image_item)
})
}

View file

@ -6,7 +6,7 @@ use futures::{channel::mpsc, future::LocalBoxFuture, FutureExt, StreamExt};
use gpui::{App, AsyncApp, BorrowAppContext, Global, Task, UpdateGlobal};
use paths::{local_settings_file_relative_path, EDITORCONFIG_NAME};
use schemars::{gen::SchemaGenerator, schema::RootSchema, JsonSchema};
use serde::{de::DeserializeOwned, Deserialize as _, Serialize};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use smallvec::SmallVec;
use std::{
any::{type_name, Any, TypeId},

View file

@ -26,6 +26,7 @@ use gpui::{
Entity, Focusable, KeyBinding, MenuItem, ParentElement, PathPromptOptions, PromptLevel,
ReadGlobal, SharedString, Styled, Task, TitlebarOptions, Window, WindowKind, WindowOptions,
};
use image_viewer::ImageInfo;
pub use open_listener::*;
use outline_panel::OutlinePanel;
use paths::{local_settings_file_relative_path, local_tasks_file_relative_path};
@ -201,6 +202,7 @@ pub fn initialize_workspace(
let active_toolchain_language =
cx.new(|cx| toolchain_selector::ActiveToolchain::new(workspace, window, cx));
let vim_mode_indicator = cx.new(|cx| vim::ModeIndicator::new(window, cx));
let image_info = cx.new(|_cx| ImageInfo::new(workspace));
let cursor_position =
cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace));
workspace.status_bar().update(cx, |status_bar, cx| {
@ -211,6 +213,7 @@ pub fn initialize_workspace(
status_bar.add_right_item(active_toolchain_language, window, cx);
status_bar.add_right_item(vim_mode_indicator, window, cx);
status_bar.add_right_item(cursor_position, window, cx);
status_bar.add_right_item(image_info, window, cx);
});
let handle = cx.entity().downgrade();
@ -4053,6 +4056,7 @@ mod tests {
app_state.client.http_client().clone(),
cx,
);
image_viewer::init(cx);
language_model::init(cx);
language_models::init(
app_state.user_store.clone(),