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;
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 Some(vec![BreadcrumbText {
166 text,
167 highlights: None,
168 font: None,
169 }])
170 }
171
172 fn clone_on_split(
173 &self,
174 _workspace_id: Option<WorkspaceId>,
175 _: &mut Window,
176 cx: &mut Context<Self>,
177 ) -> Option<Entity<Self>>
178 where
179 Self: Sized,
180 {
181 Some(cx.new(|cx| Self {
182 image_item: self.image_item.clone(),
183 project: self.project.clone(),
184 focus_handle: cx.focus_handle(),
185 }))
186 }
187
188 fn has_deleted_file(&self, cx: &App) -> bool {
189 self.image_item.read(cx).file.disk_state() == DiskState::Deleted
190 }
191 fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind {
192 workspace::item::ItemBufferKind::Singleton
193 }
194}
195
196fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String {
197 let mut path = image.file.path().clone();
198 if project.visible_worktrees(cx).count() > 1
199 && let Some(worktree) = project.worktree_for_id(image.project_path(cx).worktree_id, cx)
200 {
201 path = worktree.read(cx).root_name().join(&path);
202 }
203
204 path.display(project.path_style(cx)).to_string()
205}
206
207impl SerializableItem for ImageView {
208 fn serialized_item_kind() -> &'static str {
209 "ImageView"
210 }
211
212 fn deserialize(
213 project: Entity<Project>,
214 _workspace: WeakEntity<Workspace>,
215 workspace_id: WorkspaceId,
216 item_id: ItemId,
217 window: &mut Window,
218 cx: &mut App,
219 ) -> Task<anyhow::Result<Entity<Self>>> {
220 window.spawn(cx, async move |cx| {
221 let image_path = IMAGE_VIEWER
222 .get_image_path(item_id, workspace_id)?
223 .context("No image path found")?;
224
225 let (worktree, relative_path) = project
226 .update(cx, |project, cx| {
227 project.find_or_create_worktree(image_path.clone(), false, cx)
228 })?
229 .await
230 .context("Path not found")?;
231 let worktree_id = worktree.update(cx, |worktree, _cx| worktree.id())?;
232
233 let project_path = ProjectPath {
234 worktree_id,
235 path: relative_path,
236 };
237
238 let image_item = project
239 .update(cx, |project, cx| project.open_image(project_path, cx))?
240 .await?;
241
242 cx.update(
243 |window, cx| Ok(cx.new(|cx| ImageView::new(image_item, project, window, cx))),
244 )?
245 })
246 }
247
248 fn cleanup(
249 workspace_id: WorkspaceId,
250 alive_items: Vec<ItemId>,
251 _window: &mut Window,
252 cx: &mut App,
253 ) -> Task<anyhow::Result<()>> {
254 delete_unloaded_items(
255 alive_items,
256 workspace_id,
257 "image_viewers",
258 &IMAGE_VIEWER,
259 cx,
260 )
261 }
262
263 fn serialize(
264 &mut self,
265 workspace: &mut Workspace,
266 item_id: ItemId,
267 _closing: bool,
268 _window: &mut Window,
269 cx: &mut Context<Self>,
270 ) -> Option<Task<anyhow::Result<()>>> {
271 let workspace_id = workspace.database_id()?;
272 let image_path = self.image_item.read(cx).abs_path(cx)?;
273
274 Some(cx.background_spawn({
275 async move {
276 log::debug!("Saving image at path {image_path:?}");
277 IMAGE_VIEWER
278 .save_image_path(item_id, workspace_id, image_path)
279 .await
280 }
281 }))
282 }
283
284 fn should_serialize(&self, _event: &Self::Event) -> bool {
285 false
286 }
287}
288
289impl EventEmitter<()> for ImageView {}
290impl Focusable for ImageView {
291 fn focus_handle(&self, _cx: &App) -> FocusHandle {
292 self.focus_handle.clone()
293 }
294}
295
296impl Render for ImageView {
297 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
298 let image = self.image_item.read(cx).image.clone();
299 let checkered_background =
300 |bounds: Bounds<Pixels>, _, window: &mut Window, _cx: &mut App| {
301 let square_size: f32 = 32.0;
302
303 let start_y = bounds.origin.y.into();
304 let height: f32 = bounds.size.height.into();
305 let start_x = bounds.origin.x.into();
306 let width: f32 = bounds.size.width.into();
307
308 let mut y = start_y;
309 let mut x = start_x;
310 let mut color_swapper = true;
311 // draw checkerboard pattern
312 while y < start_y + height {
313 // Keeping track of the grid in order to be resilient to resizing
314 let start_swap = color_swapper;
315 while x < start_x + width {
316 // Clamp square dimensions to not exceed bounds
317 let square_width = square_size.min(start_x + width - x);
318 let square_height = square_size.min(start_y + height - y);
319
320 let rect = Bounds::new(
321 point(px(x), px(y)),
322 size(px(square_width), px(square_height)),
323 );
324
325 let color = if color_swapper {
326 opaque_grey(0.6, 0.4)
327 } else {
328 opaque_grey(0.7, 0.4)
329 };
330
331 window.paint_quad(fill(rect, color));
332 color_swapper = !color_swapper;
333 x += square_size;
334 }
335 x = start_x;
336 color_swapper = !start_swap;
337 y += square_size;
338 }
339 };
340
341 div().track_focus(&self.focus_handle(cx)).size_full().child(
342 div()
343 .flex()
344 .justify_center()
345 .items_center()
346 .w_full()
347 // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full
348 .h_full()
349 .child(
350 div()
351 .relative()
352 .max_w_full()
353 .max_h_full()
354 .child(
355 canvas(|_, _, _| (), checkered_background)
356 .border_2()
357 .border_color(cx.theme().styles.colors.border)
358 .size_full()
359 .absolute()
360 .top_0()
361 .left_0(),
362 )
363 .child(
364 img(image)
365 .object_fit(ObjectFit::ScaleDown)
366 .max_w_full()
367 .max_h_full()
368 .id("img"),
369 ),
370 ),
371 )
372 }
373}
374
375impl ProjectItem for ImageView {
376 type Item = ImageItem;
377
378 fn for_project_item(
379 project: Entity<Project>,
380 _: Option<&Pane>,
381 item: Entity<Self::Item>,
382 window: &mut Window,
383 cx: &mut Context<Self>,
384 ) -> Self
385 where
386 Self: Sized,
387 {
388 Self::new(item, project, window, cx)
389 }
390}
391
392pub fn init(cx: &mut App) {
393 ImageViewerSettings::register(cx);
394 workspace::register_project_item::<ImageView>(cx);
395 workspace::register_serializable_item::<ImageView>(cx);
396}
397
398mod persistence {
399 use std::path::PathBuf;
400
401 use db::{
402 query,
403 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
404 sqlez_macros::sql,
405 };
406 use workspace::{ItemId, WorkspaceDb, WorkspaceId};
407
408 pub struct ImageViewerDb(ThreadSafeConnection);
409
410 impl Domain for ImageViewerDb {
411 const NAME: &str = stringify!(ImageViewerDb);
412
413 const MIGRATIONS: &[&str] = &[sql!(
414 CREATE TABLE image_viewers (
415 workspace_id INTEGER,
416 item_id INTEGER UNIQUE,
417
418 image_path BLOB,
419
420 PRIMARY KEY(workspace_id, item_id),
421 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
422 ON DELETE CASCADE
423 ) STRICT;
424 )];
425 }
426
427 db::static_connection!(IMAGE_VIEWER, ImageViewerDb, [WorkspaceDb]);
428
429 impl ImageViewerDb {
430 query! {
431 pub async fn save_image_path(
432 item_id: ItemId,
433 workspace_id: WorkspaceId,
434 image_path: PathBuf
435 ) -> Result<()> {
436 INSERT OR REPLACE INTO image_viewers(item_id, workspace_id, image_path)
437 VALUES (?, ?, ?)
438 }
439 }
440
441 query! {
442 pub fn get_image_path(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
443 SELECT image_path
444 FROM image_viewers
445 WHERE item_id = ? AND workspace_id = ?
446 }
447 }
448 }
449}