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