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        cx.background_executor()
 99            .spawn({
100                let image_path = image_path.clone();
101                async move {
102                    IMAGE_VIEWER
103                        .save_image_path(item_id, workspace_id, image_path)
104                        .await
105                        .log_err();
106                }
107            })
108            .detach();
109    }
110
111    fn serialized_item_kind() -> Option<&'static str> {
112        Some(IMAGE_VIEWER_KIND)
113    }
114
115    fn deserialize(
116        _project: Model<Project>,
117        _workspace: WeakView<Workspace>,
118        workspace_id: WorkspaceId,
119        item_id: ItemId,
120        cx: &mut ViewContext<Pane>,
121    ) -> Task<anyhow::Result<View<Self>>> {
122        cx.spawn(|_pane, mut cx| async move {
123            let image_path = IMAGE_VIEWER
124                .get_image_path(item_id, workspace_id)?
125                .ok_or_else(|| anyhow::anyhow!("No image path found"))?;
126
127            cx.new_view(|cx| ImageView {
128                path: image_path,
129                focus_handle: cx.focus_handle(),
130            })
131        })
132    }
133
134    fn clone_on_split(
135        &self,
136        _workspace_id: WorkspaceId,
137        cx: &mut ViewContext<Self>,
138    ) -> Option<View<Self>>
139    where
140        Self: Sized,
141    {
142        Some(cx.new_view(|cx| Self {
143            path: self.path.clone(),
144            focus_handle: cx.focus_handle(),
145        }))
146    }
147}
148
149impl EventEmitter<()> for ImageView {}
150impl FocusableView for ImageView {
151    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
152        self.focus_handle.clone()
153    }
154}
155
156impl Render for ImageView {
157    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
158        let checkered_background = |bounds: Bounds<Pixels>, _, cx: &mut ElementContext| {
159            let square_size = 32.0;
160
161            let start_y = bounds.origin.y.0;
162            let height = bounds.size.height.0;
163            let start_x = bounds.origin.x.0;
164            let width = bounds.size.width.0;
165
166            let mut y = start_y;
167            let mut x = start_x;
168            let mut color_swapper = true;
169            // draw checkerboard pattern
170            while y <= start_y + height {
171                // Keeping track of the grid in order to be resilient to resizing
172                let start_swap = color_swapper;
173                while x <= start_x + width {
174                    let rect =
175                        Bounds::new(point(px(x), px(y)), size(px(square_size), px(square_size)));
176
177                    let color = if color_swapper {
178                        opaque_grey(0.6, 0.4)
179                    } else {
180                        opaque_grey(0.7, 0.4)
181                    };
182
183                    cx.paint_quad(fill(rect, color));
184                    color_swapper = !color_swapper;
185                    x += square_size;
186                }
187                x = start_x;
188                color_swapper = !start_swap;
189                y += square_size;
190            }
191        };
192
193        let checkered_background = canvas(|_, _| (), checkered_background)
194            .border_2()
195            .border_color(cx.theme().styles.colors.border)
196            .size_full()
197            .absolute()
198            .top_0()
199            .left_0();
200
201        div()
202            .track_focus(&self.focus_handle)
203            .size_full()
204            .child(checkered_background)
205            .child(
206                div()
207                    .flex()
208                    .justify_center()
209                    .items_center()
210                    .w_full()
211                    // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full
212                    .h_full()
213                    .child(
214                        img(self.path.clone())
215                            .object_fit(ObjectFit::ScaleDown)
216                            .max_w_full()
217                            .max_h_full(),
218                    ),
219            )
220    }
221}
222
223impl ProjectItem for ImageView {
224    type Item = ImageItem;
225
226    fn for_project_item(
227        _project: Model<Project>,
228        item: Model<Self::Item>,
229        cx: &mut ViewContext<Self>,
230    ) -> Self
231    where
232        Self: Sized,
233    {
234        Self {
235            path: item.read(cx).path.clone(),
236            focus_handle: cx.focus_handle(),
237        }
238    }
239}
240
241pub fn init(cx: &mut AppContext) {
242    workspace::register_project_item::<ImageView>(cx);
243    workspace::register_deserializable_item::<ImageView>(cx)
244}
245
246mod persistence {
247    use std::path::PathBuf;
248
249    use db::{define_connection, query, sqlez_macros::sql};
250    use workspace::{ItemId, WorkspaceDb, WorkspaceId};
251
252    define_connection! {
253        pub static ref IMAGE_VIEWER: ImageViewerDb<WorkspaceDb> =
254            &[sql!(
255                CREATE TABLE image_viewers (
256                    workspace_id INTEGER,
257                    item_id INTEGER UNIQUE,
258
259                    image_path BLOB,
260
261                    PRIMARY KEY(workspace_id, item_id),
262                    FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
263                    ON DELETE CASCADE
264                ) STRICT;
265            )];
266    }
267
268    impl ImageViewerDb {
269        query! {
270           pub async fn update_workspace_id(
271                new_id: WorkspaceId,
272                old_id: WorkspaceId,
273                item_id: ItemId
274            ) -> Result<()> {
275                UPDATE image_viewers
276                SET workspace_id = ?
277                WHERE workspace_id = ? AND item_id = ?
278            }
279        }
280
281        query! {
282            pub async fn save_image_path(
283                item_id: ItemId,
284                workspace_id: WorkspaceId,
285                image_path: PathBuf
286            ) -> Result<()> {
287                INSERT OR REPLACE INTO image_viewers(item_id, workspace_id, image_path)
288                VALUES (?, ?, ?)
289            }
290        }
291
292        query! {
293            pub fn get_image_path(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
294                SELECT image_path
295                FROM image_viewers
296                WHERE item_id = ? AND workspace_id = ?
297            }
298        }
299    }
300}