diff --git a/Cargo.lock b/Cargo.lock index 96a8df761b..205ffc6693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6363,6 +6363,8 @@ dependencies = [ "file_icons", "gpui", "project", + "schemars", + "serde", "settings", "theme", "ui", diff --git a/assets/settings/default.json b/assets/settings/default.json index f67f2fa965..ca5247d19b 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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. diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 7e97775a5a..5648006042 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -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 diff --git a/crates/image_viewer/src/image_info.rs b/crates/image_viewer/src/image_info.rs new file mode 100644 index 0000000000..dfee77a22f --- /dev/null +++ b/crates/image_viewer/src/image_info.rs @@ -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, + _observe_active_image: Option, + observe_image_item: Option, +} + +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, cx: &mut Context) { + 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) -> 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._observe_active_image = None; + self.observe_image_item = None; + + if let Some(image_view) = active_pane_item.and_then(|item| item.act_as::(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(); + } +} diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index c57c24e319..1782009334 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -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, @@ -31,7 +35,6 @@ impl ImageView { pub fn new( image_item: Entity, project: Entity, - cx: &mut Context, ) -> Self { cx.subscribe(&image_item, Self::on_image_event).detach(); @@ -49,7 +52,9 @@ impl ImageView { cx: &mut Context, ) { 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::(cx); - workspace::register_serializable_item::(cx) + workspace::register_serializable_item::(cx); } mod persistence { diff --git a/crates/image_viewer/src/image_viewer_settings.rs b/crates/image_viewer/src/image_viewer_settings.rs new file mode 100644 index 0000000000..165e3c4a44 --- /dev/null +++ b/crates/image_viewer/src/image_viewer_settings.rs @@ -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, + _: &mut App, + ) -> Result { + SettingsSources::::json_merge_with( + [sources.default] + .into_iter() + .chain(sources.user) + .chain(sources.server), + ) + } +} diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 84f6c7cd94..4aa42e57dd 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -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 for ImageId { } } +#[derive(Debug)] pub enum ImageItemEvent { ReloadNeeded, Reloaded, FileHandleChanged, + MetadataUpdated, } impl EventEmitter for ImageItem {} @@ -46,14 +51,106 @@ pub enum ImageStoreEvent { impl EventEmitter for ImageStore {} +#[derive(Debug, Clone, Copy)] +pub struct ImageMetadata { + pub width: u32, + pub height: u32, + pub file_size: u64, + pub colors: Option, + 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) -> Option { + 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, pub image: Arc, reload_task: Option>, + pub image_metadata: Option, } impl ImageItem { + pub async fn load_image_metadata( + image: Entity, + project: Entity, + cx: &mut AsyncApp, + ) -> Result { + 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 { id: cx.entity_id().as_non_zero_u64().into(), file: file.clone(), image, + image_metadata: None, reload_task: None, })?; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 7f2f52aee6..9d670291b6 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -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) }) } diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index fa86f0d50e..b0ecd056cc 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -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}, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 3b20a19fb4..3aa578de48 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -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(),