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