1mod image_info;
2mod image_viewer_settings;
3
4use std::path::PathBuf;
5
6use anyhow::Context as _;
7use editor::{EditorSettings, items::entry_git_aware_label_color};
8use file_icons::FileIcons;
9use gpui::{
10 AnyElement, App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable,
11 InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, Task, WeakEntity,
12 Window, canvas, div, fill, img, opaque_grey, point, size,
13};
14use persistence::IMAGE_VIEWER;
15use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent};
16use settings::Settings;
17use theme::Theme;
18use ui::prelude::*;
19use util::paths::PathExt;
20use workspace::{
21 ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items,
22 item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams},
23};
24
25pub use crate::image_info::*;
26pub use crate::image_viewer_settings::*;
27
28pub struct ImageView {
29 image_item: Entity<ImageItem>,
30 project: Entity<Project>,
31 focus_handle: FocusHandle,
32}
33
34impl ImageView {
35 pub fn new(
36 image_item: Entity<ImageItem>,
37 project: Entity<Project>,
38 window: &mut Window,
39 cx: &mut Context<Self>,
40 ) -> Self {
41 cx.subscribe(&image_item, Self::on_image_event).detach();
42 cx.on_release_in(window, |this, window, cx| {
43 let image_data = this.image_item.read(cx).image.clone();
44 if let Some(image) = image_data.clone().get_render_image(window, cx) {
45 cx.drop_image(image, None);
46 }
47 image_data.remove_asset(cx);
48 })
49 .detach();
50
51 Self {
52 image_item,
53 project,
54 focus_handle: cx.focus_handle(),
55 }
56 }
57
58 fn on_image_event(
59 &mut self,
60 _: Entity<ImageItem>,
61 event: &ImageItemEvent,
62 cx: &mut Context<Self>,
63 ) {
64 match event {
65 ImageItemEvent::MetadataUpdated
66 | ImageItemEvent::FileHandleChanged
67 | ImageItemEvent::Reloaded => {
68 cx.emit(ImageViewEvent::TitleChanged);
69 cx.notify();
70 }
71 ImageItemEvent::ReloadNeeded => {}
72 }
73 }
74}
75
76pub enum ImageViewEvent {
77 TitleChanged,
78}
79
80impl EventEmitter<ImageViewEvent> for ImageView {}
81
82impl Item for ImageView {
83 type Event = ImageViewEvent;
84
85 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
86 match event {
87 ImageViewEvent::TitleChanged => {
88 f(workspace::item::ItemEvent::UpdateTab);
89 f(workspace::item::ItemEvent::UpdateBreadcrumbs);
90 }
91 }
92 }
93
94 fn for_each_project_item(
95 &self,
96 cx: &App,
97 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
98 ) {
99 f(self.image_item.entity_id(), self.image_item.read(cx))
100 }
101
102 fn is_singleton(&self, _cx: &App) -> bool {
103 true
104 }
105
106 fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
107 let abs_path = self.image_item.read(cx).file.as_local()?.abs_path(cx);
108 let file_path = abs_path.compact().to_string_lossy().to_string();
109 Some(file_path.into())
110 }
111
112 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
113 let project_path = self.image_item.read(cx).project_path(cx);
114
115 let label_color = if ItemSettings::get_global(cx).git_status {
116 let git_status = self
117 .project
118 .read(cx)
119 .project_path_git_status(&project_path, cx)
120 .map(|status| status.summary())
121 .unwrap_or_default();
122
123 self.project
124 .read(cx)
125 .entry_for_path(&project_path, cx)
126 .map(|entry| {
127 entry_git_aware_label_color(git_status, entry.is_ignored, params.selected)
128 })
129 .unwrap_or_else(|| params.text_color())
130 } else {
131 params.text_color()
132 };
133
134 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
135 .single_line()
136 .color(label_color)
137 .when(params.preview, |this| this.italic())
138 .into_any_element()
139 }
140
141 fn tab_content_text(&self, _: usize, cx: &App) -> SharedString {
142 self.image_item
143 .read(cx)
144 .file
145 .file_name(cx)
146 .to_string_lossy()
147 .to_string()
148 .into()
149 }
150
151 fn tab_icon(&self, _: &Window, cx: &App) -> Option<Icon> {
152 let path = self.image_item.read(cx).path();
153 ItemSettings::get_global(cx)
154 .file_icons
155 .then(|| FileIcons::get_icon(path, cx))
156 .flatten()
157 .map(Icon::from_path)
158 }
159
160 fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
161 let show_breadcrumb = EditorSettings::get_global(cx).toolbar.breadcrumbs;
162 if show_breadcrumb {
163 ToolbarItemLocation::PrimaryLeft
164 } else {
165 ToolbarItemLocation::Hidden
166 }
167 }
168
169 fn breadcrumbs(&self, _theme: &Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
170 let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx);
171 Some(vec![BreadcrumbText {
172 text,
173 highlights: None,
174 font: None,
175 }])
176 }
177
178 fn clone_on_split(
179 &self,
180 _workspace_id: Option<WorkspaceId>,
181 _: &mut Window,
182 cx: &mut Context<Self>,
183 ) -> Option<Entity<Self>>
184 where
185 Self: Sized,
186 {
187 Some(cx.new(|cx| Self {
188 image_item: self.image_item.clone(),
189 project: self.project.clone(),
190 focus_handle: cx.focus_handle(),
191 }))
192 }
193}
194
195fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String {
196 let path = image.file.file_name(cx);
197 if project.visible_worktrees(cx).count() <= 1 {
198 return path.to_string_lossy().to_string();
199 }
200
201 project
202 .worktree_for_id(image.project_path(cx).worktree_id, cx)
203 .map(|worktree| {
204 PathBuf::from(worktree.read(cx).root_name())
205 .join(path)
206 .to_string_lossy()
207 .to_string()
208 })
209 .unwrap_or_else(|| path.to_string_lossy().to_string())
210}
211
212impl SerializableItem for ImageView {
213 fn serialized_item_kind() -> &'static str {
214 "ImageView"
215 }
216
217 fn deserialize(
218 project: Entity<Project>,
219 _workspace: WeakEntity<Workspace>,
220 workspace_id: WorkspaceId,
221 item_id: ItemId,
222 window: &mut Window,
223 cx: &mut App,
224 ) -> Task<gpui::Result<Entity<Self>>> {
225 window.spawn(cx, async move |cx| {
226 let image_path = IMAGE_VIEWER
227 .get_image_path(item_id, workspace_id)?
228 .ok_or_else(|| anyhow::anyhow!("No image path found"))?;
229
230 let (worktree, relative_path) = project
231 .update(cx, |project, cx| {
232 project.find_or_create_worktree(image_path.clone(), false, cx)
233 })?
234 .await
235 .context("Path not found")?;
236 let worktree_id = worktree.update(cx, |worktree, _cx| worktree.id())?;
237
238 let project_path = ProjectPath {
239 worktree_id,
240 path: relative_path.into(),
241 };
242
243 let image_item = project
244 .update(cx, |project, cx| project.open_image(project_path, cx))?
245 .await?;
246
247 cx.update(
248 |window, cx| Ok(cx.new(|cx| ImageView::new(image_item, project, window, cx))),
249 )?
250 })
251 }
252
253 fn cleanup(
254 workspace_id: WorkspaceId,
255 alive_items: Vec<ItemId>,
256 _window: &mut Window,
257 cx: &mut App,
258 ) -> Task<gpui::Result<()>> {
259 delete_unloaded_items(
260 alive_items,
261 workspace_id,
262 "image_viewers",
263 &IMAGE_VIEWER,
264 cx,
265 )
266 }
267
268 fn serialize(
269 &mut self,
270 workspace: &mut Workspace,
271 item_id: ItemId,
272 _closing: bool,
273 _window: &mut Window,
274 cx: &mut Context<Self>,
275 ) -> Option<Task<gpui::Result<()>>> {
276 let workspace_id = workspace.database_id()?;
277 let image_path = self.image_item.read(cx).file.as_local()?.abs_path(cx);
278
279 Some(cx.background_spawn({
280 async move {
281 log::debug!("Saving image at path {image_path:?}");
282 IMAGE_VIEWER
283 .save_image_path(item_id, workspace_id, image_path)
284 .await
285 }
286 }))
287 }
288
289 fn should_serialize(&self, _event: &Self::Event) -> bool {
290 false
291 }
292}
293
294impl EventEmitter<()> for ImageView {}
295impl Focusable for ImageView {
296 fn focus_handle(&self, _cx: &App) -> FocusHandle {
297 self.focus_handle.clone()
298 }
299}
300
301impl Render for ImageView {
302 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
303 let image = self.image_item.read(cx).image.clone();
304 let checkered_background = |bounds: Bounds<Pixels>,
305 _,
306 window: &mut Window,
307 _cx: &mut App| {
308 let square_size = 32.0;
309
310 let start_y = bounds.origin.y.0;
311 let height = bounds.size.height.0;
312 let start_x = bounds.origin.x.0;
313 let width = bounds.size.width.0;
314
315 let mut y = start_y;
316 let mut x = start_x;
317 let mut color_swapper = true;
318 // draw checkerboard pattern
319 while y <= start_y + height {
320 // Keeping track of the grid in order to be resilient to resizing
321 let start_swap = color_swapper;
322 while x <= start_x + width {
323 let rect =
324 Bounds::new(point(px(x), px(y)), size(px(square_size), px(square_size)));
325
326 let color = if color_swapper {
327 opaque_grey(0.6, 0.4)
328 } else {
329 opaque_grey(0.7, 0.4)
330 };
331
332 window.paint_quad(fill(rect, color));
333 color_swapper = !color_swapper;
334 x += square_size;
335 }
336 x = start_x;
337 color_swapper = !start_swap;
338 y += square_size;
339 }
340 };
341
342 let checkered_background = canvas(|_, _, _| (), checkered_background)
343 .border_2()
344 .border_color(cx.theme().styles.colors.border)
345 .size_full()
346 .absolute()
347 .top_0()
348 .left_0();
349
350 div()
351 .track_focus(&self.focus_handle(cx))
352 .size_full()
353 .child(checkered_background)
354 .child(
355 div()
356 .flex()
357 .justify_center()
358 .items_center()
359 .w_full()
360 // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full
361 .h_full()
362 .child(
363 img(image)
364 .object_fit(ObjectFit::ScaleDown)
365 .max_w_full()
366 .max_h_full()
367 .id("img"),
368 ),
369 )
370 }
371}
372
373impl ProjectItem for ImageView {
374 type Item = ImageItem;
375
376 fn for_project_item(
377 project: Entity<Project>,
378 _: Option<&Pane>,
379 item: Entity<Self::Item>,
380 window: &mut Window,
381 cx: &mut Context<Self>,
382 ) -> Self
383 where
384 Self: Sized,
385 {
386 Self::new(item, project, window, cx)
387 }
388}
389
390pub fn init(cx: &mut App) {
391 ImageViewerSettings::register(cx);
392 workspace::register_project_item::<ImageView>(cx);
393 workspace::register_serializable_item::<ImageView>(cx);
394}
395
396mod persistence {
397 use std::path::PathBuf;
398
399 use db::{define_connection, query, sqlez_macros::sql};
400 use workspace::{ItemId, WorkspaceDb, WorkspaceId};
401
402 define_connection! {
403 pub static ref IMAGE_VIEWER: ImageViewerDb<WorkspaceDb> =
404 &[sql!(
405 CREATE TABLE image_viewers (
406 workspace_id INTEGER,
407 item_id INTEGER UNIQUE,
408
409 image_path BLOB,
410
411 PRIMARY KEY(workspace_id, item_id),
412 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
413 ON DELETE CASCADE
414 ) STRICT;
415 )];
416 }
417
418 impl ImageViewerDb {
419 query! {
420 pub async fn save_image_path(
421 item_id: ItemId,
422 workspace_id: WorkspaceId,
423 image_path: PathBuf
424 ) -> Result<()> {
425 INSERT OR REPLACE INTO image_viewers(item_id, workspace_id, image_path)
426 VALUES (?, ?, ?)
427 }
428 }
429
430 query! {
431 pub fn get_image_path(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
432 SELECT image_path
433 FROM image_viewers
434 WHERE item_id = ? AND workspace_id = ?
435 }
436 }
437 }
438}