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