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 if let Some(workspace_id) = workspace_id {
99 cx.background_executor()
100 .spawn({
101 let image_path = image_path.clone();
102 async move {
103 IMAGE_VIEWER
104 .save_image_path(item_id, workspace_id, image_path)
105 .await
106 .log_err();
107 }
108 })
109 .detach();
110 }
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: Option<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 WindowContext| {
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}