image_viewer.rs

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