image_viewer.rs

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