Cargo.lock 🔗
@@ -6363,6 +6363,8 @@ dependencies = [
"file_icons",
"gpui",
"project",
+ "schemars",
+ "serde",
"settings",
"theme",
"ui",
Caleb! , tims , and Marshall Bowers created
Closes https://github.com/zed-industries/zed/issues/21281
@jansol, kindly take a look when you're free.

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>
Cargo.lock | 2
assets/settings/default.json | 7 +
crates/image_viewer/Cargo.toml | 4
crates/image_viewer/src/image_info.rs | 124 ++++++++++++++++++
crates/image_viewer/src/image_viewer.rs | 16 +
crates/image_viewer/src/image_viewer_settings.rs | 42 ++++++
crates/project/src/image_store.rs | 102 ++++++++++++++
crates/project/src/project.rs | 19 ++
crates/settings/src/settings_store.rs | 2
crates/zed/src/zed.rs | 4
10 files changed, 312 insertions(+), 10 deletions(-)
@@ -6363,6 +6363,8 @@ dependencies = [
"file_icons",
"gpui",
"project",
+ "schemars",
+ "serde",
"settings",
"theme",
"ui",
@@ -93,6 +93,13 @@
// workspace when the centered layout is used.
"right_padding": 0.2
},
+ // All settings related to the image viewer.
+ "image_viewer": {
+ // The unit for image file sizes.
+ // By default we're setting it to binary.
+ // The second option is decimal.
+ "unit": "binary"
+ },
// The key to use for adding multiple cursors
// Currently "alt" or "cmd_or_ctrl" (also aliased as
// "cmd" and "ctrl") are supported.
@@ -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
@@ -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();
+ }
+}
@@ -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 {
@@ -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),
+ )
+ }
+}
@@ -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,
})?;
@@ -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)
})
}
@@ -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},
@@ -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(),