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