diff --git a/Cargo.lock b/Cargo.lock index eff6ae575acff..9dd64263ec07f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6258,8 +6258,13 @@ dependencies = [ "db", "editor", "file_icons", + "fs", "gpui", + "image", "project", + "schemars", + "serde", + "serde_derive", "settings", "theme", "ui", diff --git a/Cargo.toml b/Cargo.toml index 6c949d927dafb..fb5439095766d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -410,7 +410,7 @@ html5ever = "0.27.0" hyper = "0.14" http = "1.1" ignore = "0.4.22" -image = "0.25.1" +image = "0.25.5" indexmap = { version = "2.7.0", features = ["serde"] } indoc = "2" itertools = "0.14.0" diff --git a/assets/settings/default.json b/assets/settings/default.json index 04b9bdc29e0be..92b2bce1b7c45 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -92,6 +92,10 @@ // workspace when the centered layout is used. "right_padding": 0.2 }, + // The unit type for image file sizes. + // By default we're setting it to binary. + // The second option is decimal + "image_file_unit_type": "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 cd10ade67b668..261fe321c9008 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -23,7 +23,15 @@ settings.workspace = true theme.workspace = true ui.workspace = true util.workspace = true +image.workspace = true workspace.workspace = true +fs.workspace = true +schemars.workspace = true +serde.workspace = true +serde_derive.workspace = true [features] test-support = ["gpui/test-support"] + +[package.metadata.cargo-machete] +ignored = ["fs", "serde_derive", "image"] diff --git a/crates/image_viewer/src/image_info.rs b/crates/image_viewer/src/image_info.rs new file mode 100644 index 0000000000000..74c84fad9a2f2 --- /dev/null +++ b/crates/image_viewer/src/image_info.rs @@ -0,0 +1,147 @@ +use crate::ImageView; +use anyhow; +use gpui::{div, Context, Entity, IntoElement, ParentElement, Render, Subscription}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsSources}; +use ui::{prelude::*, Button, LabelSize, Window}; +use workspace::{ItemHandle, StatusItemView, Workspace}; + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "snake_case")] +pub enum ImageFileSizeUnitType { + #[default] + Binary, + Decimal, +} + +impl Settings for ImageFileSizeUnitType { + const KEY: Option<&'static str> = Some("image_file_unit_type"); + + type FileContent = Self; + + fn load( + sources: SettingsSources, + _: &mut App, + ) -> Result { + sources.json_merge().or_else(|_| Ok(Self::Binary)) + } +} + +pub struct ImageInfo { + width: Option, + height: Option, + file_size: Option, + format: Option, + color_type: Option, + _observe_active_image: Option, +} + +impl ImageInfo { + pub fn new(_workspace: &Workspace, cx: &mut App) -> Self { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + ImageFileSizeUnitType::register(cx); + }); + + Self { + width: None, + height: None, + file_size: None, + format: None, + color_type: None, + _observe_active_image: None, + } + } + + fn update_metadata(&mut self, image_view: &Entity, cx: &mut Context) { + let image_item = image_view.read(cx).image_item.read(cx); + + if let Some(meta) = &image_item.image_meta { + self.width = Some(meta.width); + self.height = Some(meta.height); + self.file_size = Some(meta.file_size); + self.format = Some(meta.format.clone()); + self.color_type = Some(meta.color_type.to_string()); + } else { + self.width = None; + self.height = None; + self.file_size = None; + self.format = None; + self.color_type = None + } + cx.notify(); + } + + fn format_file_size(&self, size: u64, image_unit_type: &ImageFileSizeUnitType) -> String { + match image_unit_type { + ImageFileSizeUnitType::Binary => { + if size < 1024 { + format!("{}B", size) + } else if size < 1024 * 1024 { + format!("{:.1}KB", size as f64 / 1024.0) + } else { + format!("{:.1}MB", size as f64 / (1024.0 * 1024.0)) + } + } + ImageFileSizeUnitType::Decimal => { + if size < 1000 { + format!("{}B", size) + } 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 unit_type = ImageFileSizeUnitType::get_global(cx); + + let components = [ + self.width + .and_then(|w| self.height.map(|h| format!("{}x{}", w, h))), + self.file_size.map(|s| self.format_file_size(s, unit_type)), + self.color_type.clone(), + self.format.clone(), + ]; + + let text = components + .into_iter() + .flatten() + .collect::>() + .join(" • "); + + div().when(!text.is_empty(), |el| { + el.child(Button::new("image-metadata", text).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; + + 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.width = None; + self.height = None; + self.file_size = None; + self.format = None; + self.color_type = None; + } + cx.notify(); + } +} diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index c57c24e31942b..f85381520d1dc 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +pub mod image_info; use anyhow::Context as _; use editor::items::entry_git_aware_label_color; @@ -9,10 +9,12 @@ use gpui::{ ParentElement, Render, Styled, Task, WeakEntity, Window, }; use persistence::IMAGE_VIEWER; -use project::{image_store::ImageItemEvent, ImageItem, Project, ProjectPath}; -use settings::Settings; +use std::path::PathBuf; use theme::Theme; use ui::prelude::*; + +use project::{image_store::ImageItemEvent, ImageItem, Project, ProjectPath}; +use settings::Settings; use util::paths::PathExt; use workspace::{ item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams}, @@ -35,6 +37,28 @@ impl ImageView { cx: &mut Context, ) -> Self { cx.subscribe(&image_item, Self::on_image_event).detach(); + + if image_item.read(cx).image_meta.is_none() { + let image_item_clone = image_item.downgrade(); + let project_clone = project.downgrade(); + + cx.spawn(|view, mut cx| async move { + if let (Some(image_item), Some(project)) = + (image_item_clone.upgrade(), project_clone.upgrade()) + { + let metadata = + ImageItem::image_info(image_item.clone(), project, &mut cx).await?; + + image_item.update(&mut cx, |image_item, _cx| { + image_item.image_meta = Some(metadata); + })?; + } + + view.update(&mut cx, |_, cx| cx.notify()) + }) + .detach(); + } + Self { image_item, project, @@ -145,8 +169,9 @@ impl Item for ImageView { fn breadcrumbs(&self, _theme: &Theme, cx: &App) -> Option> { let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx); + Some(vec![BreadcrumbText { - text, + text: text, highlights: None, font: None, }]) diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index a837a5efd5993..68014179d7f6d 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -6,8 +6,9 @@ use 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, AsyncAppContext, Context, Entity, EventEmitter, Img, Subscription, Task, WeakEntity }; +use image::{ExtendedColorType, GenericImageView, ImageFormat, ImageReader}; use language::{DiskState, File}; use rpc::{AnyProtoClient, ErrorExt as _}; use std::ffi::OsStr; @@ -32,6 +33,7 @@ impl From for ImageId { } } +#[derive(Debug)] pub enum ImageItemEvent { ReloadNeeded, Reloaded, @@ -46,14 +48,143 @@ pub enum ImageStoreEvent { impl EventEmitter for ImageStore {} +#[derive(Clone)] +pub struct ImageItemMeta { + pub width: u32, + pub height: u32, + pub file_size: u64, + pub color_type: String, + pub format: String, +} + pub struct ImageItem { pub id: ImageId, pub file: Arc, pub image: Arc, reload_task: Option>, + pub image_meta: Option, +} + +fn image_color_type_description(color_type: ExtendedColorType) -> String { + let (channels, bits_per_channel) = match color_type { + 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), + + _ => (0, 0), + }; + + if channels == 0 { + "unknown color type".to_string() + } else { + let bits_per_pixel = channels * bits_per_channel; + format!("{} channels, {} bits per pixel", channels, bits_per_pixel) + } } impl ImageItem { + pub async fn image_info( + image: Entity, + project: Entity, + cx: &mut AsyncAppContext, + ) -> Result { + let project_path = cx + .update(|cx| image.read(cx).project_path(cx)) + .context("Failed to get project path")?; + + let worktree = cx + .update(|cx| { + project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + }) + .context("Failed to get worktree")? + .ok_or_else(|| anyhow::anyhow!("Worktree not found"))?; + + let worktree_root = cx + .update(|cx| worktree.read(cx).abs_path()) + .context("Failed to get worktree root path")?; + + let image_path = cx + .update(|cx| image.read(cx).path().clone()) + .context("Failed to get image path")?; + + let path = if image_path.is_absolute() { + image_path.to_path_buf() + } else { + worktree_root.join(image_path) + }; + + if !path.exists() { + anyhow::bail!("File does not exist at path: {:?}", path); + } + + let fs = project + .update(cx, |project, _| project.fs().clone()) + .context("Failed to get filesystem")?; + + let img_bytes = fs + .load_bytes(&path) + .await + .context("Could not load image bytes")?; + let img_format = image::guess_format(&img_bytes).context("Could not guess image format")?; + + let img_format_str = match img_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", + }; + + let path_clone = path.clone(); + let image_result = smol::unblock(move || ImageReader::open(&path_clone)?.decode()).await?; + + let img = image_result; + let dimensions_result = smol::unblock(move || { + let dimensions = img.dimensions(); + let img_color_type = image_color_type_description(img.color().into()); + Ok::<_, anyhow::Error>(( + dimensions.0, + dimensions.1, + img_format_str.to_string(), + img_color_type, + )) + }) + .await?; + + let (width, height, format, color_type) = dimensions_result; + + let file_metadata = fs + .metadata(path.as_path()) + .await + .context("Failed to access image data")? + .ok_or_else(|| anyhow::anyhow!("No metadata found"))?; + let file_size = file_metadata.len; + + Ok(ImageItemMeta { + width, + height, + file_size, + format, + color_type, + }) + } + pub fn project_path(&self, cx: &App) -> ProjectPath { ProjectPath { worktree_id: self.file.worktree_id(cx), @@ -142,9 +273,17 @@ impl ProjectItem for ImageItem { // Since we do not have a way to toggle to an editor if Img::extensions().contains(&ext) && !ext.contains("svg") { Some(cx.spawn(|mut cx| async move { - project + let image_model = project .update(&mut cx, |project, cx| project.open_image(path, cx))? - .await + .await?; + let image_metadata = + Self::image_info(image_model.clone(), project, &mut cx).await?; + + image_model.update(&mut cx, |image_model, _cx| { + image_model.image_meta = Some(image_metadata); + })?; + + Ok(image_model) })) } else { None @@ -391,6 +530,11 @@ impl ImageStoreImpl for Entity { id: cx.entity_id().as_non_zero_u64().into(), file: file.clone(), image, + // width: None, + // file_size: None, + // height: None, + // color_type: None, + image_meta: None, reload_task: None, })?; diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index b0a38414469a7..477df9640dc09 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, AsyncAppContext, 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 d1235e64c449c..547e59e81efd9 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -28,6 +28,7 @@ use gpui::{ PromptLevel, ReadGlobal, SharedString, Styled, Task, TitlebarOptions, Window, WindowKind, WindowOptions, }; +use image_viewer::image_info::ImageInfo; pub use open_listener::*; use outline_panel::OutlinePanel; use paths::{local_settings_file_relative_path, local_tasks_file_relative_path}; @@ -201,8 +202,8 @@ 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 cursor_position = - cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace)); + let image_metadata = cx.new(|cx| ImageInfo::new(workspace, cx)); + let cursor_position = cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace)); workspace.status_bar().update(cx, |status_bar, cx| { status_bar.add_left_item(diagnostic_summary, window, cx); status_bar.add_left_item(activity_indicator, window, cx); @@ -211,6 +212,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_metadata, window, cx); }); auto_update_ui::notify_of_any_new_update(window, cx);