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 is_singleton(&self, _cx: &App) -> bool {
102 true
103 }
104
105 fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
106 let abs_path = self.image_item.read(cx).abs_path(cx)?;
107 let file_path = abs_path.compact().to_string_lossy().into_owned();
108 Some(file_path.into())
109 }
110
111 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
112 let project_path = self.image_item.read(cx).project_path(cx);
113
114 let label_color = if ItemSettings::get_global(cx).git_status {
115 let git_status = self
116 .project
117 .read(cx)
118 .project_path_git_status(&project_path, cx)
119 .map(|status| status.summary())
120 .unwrap_or_default();
121
122 self.project
123 .read(cx)
124 .entry_for_path(&project_path, cx)
125 .map(|entry| {
126 entry_git_aware_label_color(git_status, entry.is_ignored, params.selected)
127 })
128 .unwrap_or_else(|| params.text_color())
129 } else {
130 params.text_color()
131 };
132
133 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
134 .single_line()
135 .color(label_color)
136 .when(params.preview, |this| this.italic())
137 .into_any_element()
138 }
139
140 fn tab_content_text(&self, _: usize, cx: &App) -> SharedString {
141 self.image_item
142 .read(cx)
143 .file
144 .file_name(cx)
145 .to_string()
146 .into()
147 }
148
149 fn tab_icon(&self, _: &Window, cx: &App) -> Option<Icon> {
150 let path = self.image_item.read(cx).abs_path(cx)?;
151 ItemSettings::get_global(cx)
152 .file_icons
153 .then(|| FileIcons::get_icon(&path, cx))
154 .flatten()
155 .map(Icon::from_path)
156 }
157
158 fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
159 let show_breadcrumb = EditorSettings::get_global(cx).toolbar.breadcrumbs;
160 if show_breadcrumb {
161 ToolbarItemLocation::PrimaryLeft
162 } else {
163 ToolbarItemLocation::Hidden
164 }
165 }
166
167 fn breadcrumbs(&self, _theme: &Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
168 let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx);
169 Some(vec![BreadcrumbText {
170 text,
171 highlights: None,
172 font: None,
173 }])
174 }
175
176 fn clone_on_split(
177 &self,
178 _workspace_id: Option<WorkspaceId>,
179 _: &mut Window,
180 cx: &mut Context<Self>,
181 ) -> Option<Entity<Self>>
182 where
183 Self: Sized,
184 {
185 Some(cx.new(|cx| Self {
186 image_item: self.image_item.clone(),
187 project: self.project.clone(),
188 focus_handle: cx.focus_handle(),
189 }))
190 }
191
192 fn has_deleted_file(&self, cx: &App) -> bool {
193 self.image_item.read(cx).file.disk_state() == DiskState::Deleted
194 }
195}
196
197fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String {
198 let mut path = image.file.path().clone();
199 if project.visible_worktrees(cx).count() > 1
200 && let Some(worktree) = project.worktree_for_id(image.project_path(cx).worktree_id, cx)
201 {
202 path = worktree.read(cx).root_name().join(&path);
203 }
204
205 path.display(project.path_style(cx)).to_string()
206}
207
208impl SerializableItem for ImageView {
209 fn serialized_item_kind() -> &'static str {
210 "ImageView"
211 }
212
213 fn deserialize(
214 project: Entity<Project>,
215 _workspace: WeakEntity<Workspace>,
216 workspace_id: WorkspaceId,
217 item_id: ItemId,
218 window: &mut Window,
219 cx: &mut App,
220 ) -> Task<anyhow::Result<Entity<Self>>> {
221 window.spawn(cx, async move |cx| {
222 let image_path = IMAGE_VIEWER
223 .get_image_path(item_id, workspace_id)?
224 .context("No image path found")?;
225
226 let (worktree, relative_path) = project
227 .update(cx, |project, cx| {
228 project.find_or_create_worktree(image_path.clone(), false, cx)
229 })?
230 .await
231 .context("Path not found")?;
232 let worktree_id = worktree.update(cx, |worktree, _cx| worktree.id())?;
233
234 let project_path = ProjectPath {
235 worktree_id,
236 path: relative_path,
237 };
238
239 let image_item = project
240 .update(cx, |project, cx| project.open_image(project_path, cx))?
241 .await?;
242
243 cx.update(
244 |window, cx| Ok(cx.new(|cx| ImageView::new(image_item, project, window, cx))),
245 )?
246 })
247 }
248
249 fn cleanup(
250 workspace_id: WorkspaceId,
251 alive_items: Vec<ItemId>,
252 _window: &mut Window,
253 cx: &mut App,
254 ) -> Task<anyhow::Result<()>> {
255 delete_unloaded_items(
256 alive_items,
257 workspace_id,
258 "image_viewers",
259 &IMAGE_VIEWER,
260 cx,
261 )
262 }
263
264 fn serialize(
265 &mut self,
266 workspace: &mut Workspace,
267 item_id: ItemId,
268 _closing: bool,
269 _window: &mut Window,
270 cx: &mut Context<Self>,
271 ) -> Option<Task<anyhow::Result<()>>> {
272 let workspace_id = workspace.database_id()?;
273 let image_path = self.image_item.read(cx).abs_path(cx)?;
274
275 Some(cx.background_spawn({
276 async move {
277 log::debug!("Saving image at path {image_path:?}");
278 IMAGE_VIEWER
279 .save_image_path(item_id, workspace_id, image_path)
280 .await
281 }
282 }))
283 }
284
285 fn should_serialize(&self, _event: &Self::Event) -> bool {
286 false
287 }
288}
289
290impl EventEmitter<()> for ImageView {}
291impl Focusable for ImageView {
292 fn focus_handle(&self, _cx: &App) -> FocusHandle {
293 self.focus_handle.clone()
294 }
295}
296
297impl Render for ImageView {
298 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
299 let image = self.image_item.read(cx).image.clone();
300 let checkered_background = |bounds: Bounds<Pixels>,
301 _,
302 window: &mut Window,
303 _cx: &mut App| {
304 let square_size = 32.0;
305
306 let start_y = bounds.origin.y.into();
307 let height: f32 = bounds.size.height.into();
308 let start_x = bounds.origin.x.into();
309 let width: f32 = bounds.size.width.into();
310
311 let mut y = start_y;
312 let mut x = start_x;
313 let mut color_swapper = true;
314 // draw checkerboard pattern
315 while y <= start_y + height {
316 // Keeping track of the grid in order to be resilient to resizing
317 let start_swap = color_swapper;
318 while x <= start_x + width {
319 let rect =
320 Bounds::new(point(px(x), px(y)), size(px(square_size), px(square_size)));
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 let checkered_background = canvas(|_, _, _| (), checkered_background)
339 .border_2()
340 .border_color(cx.theme().styles.colors.border)
341 .size_full()
342 .absolute()
343 .top_0()
344 .left_0();
345
346 div()
347 .track_focus(&self.focus_handle(cx))
348 .size_full()
349 .child(checkered_background)
350 .child(
351 div()
352 .flex()
353 .justify_center()
354 .items_center()
355 .w_full()
356 // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full
357 .h_full()
358 .child(
359 img(image)
360 .object_fit(ObjectFit::ScaleDown)
361 .max_w_full()
362 .max_h_full()
363 .id("img"),
364 ),
365 )
366 }
367}
368
369impl ProjectItem for ImageView {
370 type Item = ImageItem;
371
372 fn for_project_item(
373 project: Entity<Project>,
374 _: Option<&Pane>,
375 item: Entity<Self::Item>,
376 window: &mut Window,
377 cx: &mut Context<Self>,
378 ) -> Self
379 where
380 Self: Sized,
381 {
382 Self::new(item, project, window, cx)
383 }
384}
385
386pub fn init(cx: &mut App) {
387 ImageViewerSettings::register(cx);
388 workspace::register_project_item::<ImageView>(cx);
389 workspace::register_serializable_item::<ImageView>(cx);
390}
391
392mod persistence {
393 use std::path::PathBuf;
394
395 use db::{
396 query,
397 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
398 sqlez_macros::sql,
399 };
400 use workspace::{ItemId, WorkspaceDb, WorkspaceId};
401
402 pub struct ImageViewerDb(ThreadSafeConnection);
403
404 impl Domain for ImageViewerDb {
405 const NAME: &str = stringify!(ImageViewerDb);
406
407 const MIGRATIONS: &[&str] = &[sql!(
408 CREATE TABLE image_viewers (
409 workspace_id INTEGER,
410 item_id INTEGER UNIQUE,
411
412 image_path BLOB,
413
414 PRIMARY KEY(workspace_id, item_id),
415 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
416 ON DELETE CASCADE
417 ) STRICT;
418 )];
419 }
420
421 db::static_connection!(IMAGE_VIEWER, ImageViewerDb, [WorkspaceDb]);
422
423 impl ImageViewerDb {
424 query! {
425 pub async fn save_image_path(
426 item_id: ItemId,
427 workspace_id: WorkspaceId,
428 image_path: PathBuf
429 ) -> Result<()> {
430 INSERT OR REPLACE INTO image_viewers(item_id, workspace_id, image_path)
431 VALUES (?, ?, ?)
432 }
433 }
434
435 query! {
436 pub fn get_image_path(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
437 SELECT image_path
438 FROM image_viewers
439 WHERE item_id = ? AND workspace_id = ?
440 }
441 }
442 }
443}