1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3mod application;
12mod convert_events;
13mod event;
14mod net;
15mod window;
16
17#[cfg(feature = "accessibility")]
18mod accessibility;
19
20pub use crate::application::ApaceApplication;
21pub use crate::event::{ApaceShellEvent, ApaceShellProxy};
22pub use crate::window::{View, WindowConfig};
23
24#[cfg(feature = "data-uri")]
25pub use crate::net::DataUriNetProvider;
26
27#[cfg(all(
28 feature = "file-dialog",
29 any(
30 target_os = "windows",
31 target_os = "macos",
32 target_os = "linux",
33 target_os = "dragonfly",
34 target_os = "freebsd",
35 target_os = "netbsd",
36 target_os = "openbsd"
37 )
38))]
39use apace_traits::shell::FileDialogFilter;
40use apace_traits::shell::{ImePurpose, ShellProvider};
41use std::sync::atomic::{AtomicBool, Ordering};
42use std::sync::{Arc, Mutex};
43use wingy::cursor::{Cursor, CursorIcon};
44use wingy::dpi::{LogicalPosition, LogicalSize};
45pub use wingy::event_loop::{ControlFlow, EventLoop, EventLoopProxy};
46pub use wingy::window::Window;
47use wingy::window::{
48 ImeCapabilities, ImeEnableRequest, ImeHint, ImePurpose as WindowImePurpose, ImeRequest,
49 ImeRequestData,
50};
51
52#[derive(Default)]
53pub struct Config {
54 pub stylesheets: Vec<String>,
55 pub base_url: Option<String>,
56}
57
58pub fn create_default_event_loop() -> EventLoop {
60 let mut ev_builder = EventLoop::builder();
61 #[cfg(target_os = "android")]
62 {
63 use wingy::platform::android::EventLoopBuilderExtAndroid;
64 ev_builder.with_android_app(current_android_app());
65 }
66
67 let event_loop = ev_builder.build().unwrap();
68 event_loop.set_control_flow(ControlFlow::Wait);
69
70 event_loop
71}
72
73#[cfg(target_os = "android")]
74static ANDROID_APP: std::sync::OnceLock<android_activity::AndroidApp> = std::sync::OnceLock::new();
75
76#[cfg(target_os = "android")]
77#[cfg_attr(docsrs, doc(cfg(target_os = "android")))]
78pub fn set_android_app(app: android_activity::AndroidApp) {
80 ANDROID_APP.set(app).unwrap()
81}
82
83#[cfg(target_os = "android")]
84#[cfg_attr(docsrs, doc(cfg(target_os = "android")))]
85pub fn current_android_app() -> android_activity::AndroidApp {
88 ANDROID_APP.get().unwrap().clone()
89}
90
91pub struct ApaceShellProvider {
92 window: Arc<dyn Window>,
93 proxy: ApaceShellProxy,
94 ime_password: AtomicBool,
95 ime_enabled: AtomicBool,
96 ime_area: Mutex<Option<[f32; 4]>>,
97 #[cfg(any(
98 target_os = "ios",
99 all(target_os = "android", feature = "android-game-activity")
100 ))]
101 ime_context: Mutex<Option<(String, usize, usize)>>,
102}
103impl ApaceShellProvider {
104 pub fn new(window: Arc<dyn Window>, proxy: ApaceShellProxy) -> Self {
105 Self {
106 window,
107 proxy,
108 ime_password: AtomicBool::new(false),
109 ime_enabled: AtomicBool::new(false),
110 ime_area: Mutex::new(None),
111 #[cfg(any(
112 target_os = "ios",
113 all(target_os = "android", feature = "android-game-activity")
114 ))]
115 ime_context: Mutex::new(None),
116 }
117 }
118 fn ime_data(&self) -> ImeRequestData {
119 let purpose = if self.ime_password.load(Ordering::Relaxed) {
120 WindowImePurpose::Password
121 } else {
122 WindowImePurpose::Normal
123 };
124 ImeRequestData::default().with_hint_and_purpose(ImeHint::NONE, purpose)
125 }
126}
127
128impl ShellProvider for ApaceShellProvider {
129 fn set_ime_purpose(&self, purpose: ImePurpose) {
130 let password = purpose == ImePurpose::Password;
131 if self.ime_password.swap(password, Ordering::Relaxed) != password
132 && self.ime_enabled.load(Ordering::Relaxed)
133 {
134 let _ = self
135 .window
136 .request_ime_update(ImeRequest::Update(self.ime_data()));
137 }
138 }
139 fn request_redraw(&self) {
140 self.window.request_redraw();
141 }
142 fn set_cursor(&self, icon: Option<CursorIcon>) {
143 match icon {
144 Some(icon) => {
145 self.window.set_cursor_visible(true);
146 self.window.set_cursor(Cursor::Icon(icon));
147 }
148 None => {
149 self.window.set_cursor(Cursor::Icon(CursorIcon::Default));
150 self.window.set_cursor_visible(false)
151 }
152 }
153 }
154 fn set_window_title(&self, title: String) {
155 self.window.set_title(&title);
156 }
157 fn set_ime_enabled(&self, is_enabled: bool) {
158 *self.ime_area.lock().unwrap() = None;
159 #[cfg(any(
160 target_os = "ios",
161 all(target_os = "android", feature = "android-game-activity")
162 ))]
163 {
164 *self.ime_context.lock().unwrap() = None;
165 }
166 if is_enabled {
167 let data = self.ime_data().with_cursor_area(
168 LogicalPosition::new(0., 0.).into(),
169 LogicalSize::new(0., 0.).into(),
170 );
171 #[cfg(any(
172 target_os = "ios",
173 all(target_os = "android", feature = "android-game-activity")
174 ))]
175 let data = data.with_surrounding_text(
176 wingy::window::ImeSurroundingText::new(String::new(), 0, 0).unwrap(),
177 );
178 let capabilities = ImeCapabilities::new()
179 .with_hint_and_purpose()
180 .with_cursor_area();
181 #[cfg(any(
182 target_os = "ios",
183 all(target_os = "android", feature = "android-game-activity")
184 ))]
185 let capabilities = capabilities.with_surrounding_text();
186 let result = self.window.request_ime_update(ImeRequest::Enable(
187 ImeEnableRequest::new(capabilities, data).unwrap(),
188 ));
189 self.ime_enabled.store(result.is_ok(), Ordering::Relaxed);
190 } else {
191 self.ime_enabled.store(false, Ordering::Relaxed);
192 let _ = self.window.request_ime_update(ImeRequest::Disable);
193 }
194 }
195 fn set_ime_cursor_area(&self, x: f32, y: f32, width: f32, height: f32) {
196 if !self.ime_enabled.load(Ordering::Relaxed) {
197 return;
198 }
199 {
200 let mut previous = self.ime_area.lock().unwrap();
201 let area = [x, y, width, height];
202 if *previous == Some(area) {
203 return;
204 }
205 *previous = Some(area);
206 }
207 let _ = self.window.request_ime_update(ImeRequest::Update(
209 ImeRequestData::default().with_cursor_area(
210 LogicalPosition::new(x, y).into(),
211 LogicalSize::new(width, height).into(),
212 ),
213 ));
214 }
215
216 fn invalidate_ime_context(&self) {
217 #[cfg(any(
218 target_os = "ios",
219 all(target_os = "android", feature = "android-game-activity")
220 ))]
221 {
222 *self.ime_context.lock().unwrap() = None;
223 }
224 }
225 fn supports_ime_surrounding_text(&self) -> bool {
226 cfg!(any(
227 target_os = "ios",
228 all(target_os = "android", feature = "android-game-activity")
229 ))
230 }
231 fn set_ime_surrounding_text(&self, text: &str, cursor: usize, anchor: usize) {
232 #[cfg(any(
233 target_os = "ios",
234 all(target_os = "android", feature = "android-game-activity")
235 ))]
236 {
237 if !self.ime_enabled.load(Ordering::Relaxed) {
238 return;
239 }
240 let Ok(context) =
241 wingy::window::ImeSurroundingText::new(text.to_owned(), cursor, anchor)
242 else {
243 return;
244 };
245 {
246 let mut previous = self.ime_context.lock().unwrap();
247 if previous
248 .as_ref()
249 .is_some_and(|(s, c, a)| s == text && *c == cursor && *a == anchor)
250 {
251 return;
252 }
253 *previous = Some((text.to_owned(), cursor, anchor));
254 }
255 let _ = self.window.request_ime_update(ImeRequest::Update(
256 ImeRequestData::default().with_surrounding_text(context),
257 ));
258 }
259 #[cfg(not(any(
260 target_os = "ios",
261 all(target_os = "android", feature = "android-game-activity")
262 )))]
263 let _ = (text, cursor, anchor);
264 }
265
266 fn request_window_close(&self) {
267 self.proxy.send_event(ApaceShellEvent::CloseWindow {
268 window_id: self.window.id(),
269 });
270 }
271 fn set_window_minimized(&self, minimized: bool) {
272 self.window.set_minimized(minimized);
273 }
274 fn set_window_maximized(&self, maximized: bool) {
275 self.window.set_maximized(maximized);
276 }
277 fn is_window_maximized(&self) -> bool {
278 self.window.is_maximized()
279 }
280 fn set_window_decorations(&self, decorations: bool) {
281 self.window.set_decorations(decorations);
282 }
283 fn drag_window(&self) {
284 let _ = self.window.drag_window();
285 }
286
287 #[cfg(all(
288 feature = "clipboard",
289 any(
290 target_os = "windows",
291 target_os = "macos",
292 target_os = "linux",
293 target_os = "dragonfly",
294 target_os = "freebsd",
295 target_os = "netbsd",
296 target_os = "openbsd"
297 )
298 ))]
299 fn get_clipboard_text(&self) -> Result<String, apace_traits::shell::ClipboardError> {
300 let mut cb = arboard::Clipboard::new().unwrap();
301 cb.get_text()
302 .map_err(|_| apace_traits::shell::ClipboardError)
303 }
304
305 #[cfg(all(
306 feature = "clipboard",
307 any(
308 target_os = "windows",
309 target_os = "macos",
310 target_os = "linux",
311 target_os = "dragonfly",
312 target_os = "freebsd",
313 target_os = "netbsd",
314 target_os = "openbsd"
315 )
316 ))]
317 fn set_clipboard_text(&self, text: String) -> Result<(), apace_traits::shell::ClipboardError> {
318 let mut cb = arboard::Clipboard::new().unwrap();
319 cb.set_text(text.to_owned())
320 .map_err(|_| apace_traits::shell::ClipboardError)
321 }
322
323 #[cfg(all(
324 feature = "file-dialog",
325 any(
326 target_os = "windows",
327 target_os = "macos",
328 target_os = "linux",
329 target_os = "dragonfly",
330 target_os = "freebsd",
331 target_os = "netbsd",
332 target_os = "openbsd"
333 )
334 ))]
335 fn open_file_dialog(
336 &self,
337 multiple: bool,
338 filter: Option<FileDialogFilter>,
339 ) -> Vec<std::path::PathBuf> {
340 let mut dialog = rfd::FileDialog::new();
341 if let Some(FileDialogFilter { name, extensions }) = filter {
342 dialog = dialog.add_filter(&name, &extensions);
343 }
344 let files = if multiple {
345 dialog.pick_files()
346 } else {
347 dialog.pick_file().map(|file| vec![file])
348 };
349 files.unwrap_or_default()
350 }
351}