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