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