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},
 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        if Img::extensions().contains(&ext) {
 41            Some(cx.spawn(|mut cx| async move {
 42                let abs_path = project
 43                    .read_with(&cx, |project, cx| project.absolute_path(&path, cx))?
 44                    .ok_or_else(|| anyhow::anyhow!("Failed to find the absolute path"))?;
 45
 46                cx.new_model(|_| ImageItem {
 47                    path: abs_path,
 48                    project_path: path,
 49                })
 50            }))
 51        } else {
 52            None
 53        }
 54    }
 55
 56    fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
 57        None
 58    }
 59
 60    fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
 61        Some(self.project_path.clone())
 62    }
 63}
 64
 65pub struct ImageView {
 66    path: PathBuf,
 67    focus_handle: FocusHandle,
 68}
 69
 70impl Item for ImageView {
 71    type Event = ();
 72
 73    fn tab_content(
 74        &self,
 75        _detail: Option<usize>,
 76        selected: bool,
 77        _cx: &WindowContext,
 78    ) -> AnyElement {
 79        let title = self
 80            .path
 81            .file_name()
 82            .unwrap_or_else(|| self.path.as_os_str())
 83            .to_string_lossy()
 84            .to_string();
 85        Label::new(title)
 86            .single_line()
 87            .color(if selected {
 88                Color::Default
 89            } else {
 90                Color::Muted
 91            })
 92            .into_any_element()
 93    }
 94
 95    fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
 96        let item_id = cx.entity_id().as_u64();
 97        let workspace_id = workspace.database_id();
 98        let image_path = self.path.clone();
 99
100        cx.background_executor()
101            .spawn({
102                let image_path = image_path.clone();
103                async move {
104                    IMAGE_VIEWER
105                        .save_image_path(item_id, workspace_id, image_path)
106                        .await
107                        .log_err();
108                }
109            })
110            .detach();
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: 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 ElementContext| {
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}