image_viewer.rs

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