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 WindowContext| {
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}