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