1use ferroui_base::animation::easings::IEasing;
11use ferroui_base::media::{Color, Colors};
12use ferroui_base::reactive::IDisposable;
13use ferroui_base::{Rect, Size, Thickness};
14use ferroui_controls::platform::{
15 IInputPane, IInsetsManager, InputPaneBase, InputPaneState, InputPaneStateEventArgs, InsetsManagerBase,
16 SafeAreaChangedArgs, SystemBarTheme,
17};
18use std::cell::Cell;
19use std::rc::{Rc, Weak};
20use std::time::Duration;
21
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub(crate) struct RootInsets {
25 pub left: i32,
27 pub top: i32,
28 pub right: i32,
29 pub bottom: i32,
30 pub navigation_bars_bottom: i32,
32 pub ime_bottom: i32,
34 pub ime_visible: bool,
35 pub system_bars_visible: bool,
37}
38
39impl RootInsets {
40 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
42 pub fn from_array(values: &[i32]) -> Option<RootInsets> {
43 match values {
44 [left, top, right, bottom, navigation_bars_bottom, ime_bottom, ime_visible, system_bars_visible] => {
45 Some(RootInsets {
46 left: *left,
47 top: *top,
48 right: *right,
49 bottom: *bottom,
50 navigation_bars_bottom: *navigation_bars_bottom,
51 ime_bottom: *ime_bottom,
52 ime_visible: *ime_visible != 0,
53 system_bars_visible: *system_bars_visible != 0,
54 })
55 }
56 _ => None,
57 }
58 }
59}
60
61pub(crate) trait IInsetsWindow {
63 fn sdk_int(&self) -> i32;
65
66 fn has_window(&self) -> bool;
68
69 fn root_insets(&self) -> Option<RootInsets>;
71
72 fn is_display_edge_to_edge_forced(&self) -> bool;
75
76 fn set_layout_in_display_cutout_mode(&self, short_edges: bool);
77 fn set_decor_fits_system_windows(&self, decor_fits_system_windows: bool);
78 fn add_translucent_bars(&self);
80 fn set_system_bar_color(&self, color: Color);
83 fn set_navigation_bar_contrast_enforced(&self, enforced: bool);
84 fn appearance_light_status_bars(&self) -> bool;
87 fn set_appearance_light_bars(&self, light: bool);
88 fn set_system_bars_visible(&self, visible: bool);
89}
90
91pub(crate) trait IInsetsTopLevel {
93 fn top_level_render_scaling(&self) -> f64;
95 fn top_level_client_size(&self) -> Size;
97}
98
99pub struct AndroidInsetsManager {
100 window: Box<dyn IInsetsWindow>,
101 top_level: Weak<dyn IInsetsTopLevel>,
102 displays_edge_to_edge: Cell<bool>,
103 system_ui_visibility: Cell<Option<bool>>,
104 status_bar_theme: Cell<Option<SystemBarTheme>>,
105 is_default_system_bar_light_theme: Cell<Option<bool>>,
106 system_bar_color: Cell<Option<Color>>,
107 state: Cell<InputPaneState>,
108 previous_rect: Cell<Rect>,
109 previous_ime_inset: Cell<Option<i32>>,
110 display_edge_to_edge_preference: Cell<bool>,
111 uses_legacy_layouts: bool,
112 is_display_edge_to_edge_forced: bool,
113 safe_area_changed: InsetsManagerBase,
114 state_changed: InputPaneBase,
115}
116
117const Q: i32 = 29;
119const R: i32 = 30;
121
122impl AndroidInsetsManager {
123 pub(crate) fn new(
132 window: Box<dyn IInsetsWindow>,
133 top_level: Weak<dyn IInsetsTopLevel>,
134 ) -> Rc<AndroidInsetsManager> {
135 let is_display_edge_to_edge_forced = window.is_display_edge_to_edge_forced();
138 let uses_legacy_layouts = window.sdk_int() < R;
139
140 let this = Rc::new(AndroidInsetsManager {
141 window,
142 top_level,
143 displays_edge_to_edge: Cell::new(false),
144 system_ui_visibility: Cell::new(None),
145 status_bar_theme: Cell::new(None),
146 is_default_system_bar_light_theme: Cell::new(None),
147 system_bar_color: Cell::new(None),
148 state: Cell::new(InputPaneState::Closed),
149 previous_rect: Cell::new(Rect::default()),
150 previous_ime_inset: Cell::new(None),
151 display_edge_to_edge_preference: Cell::new(false),
152 uses_legacy_layouts,
153 is_display_edge_to_edge_forced,
154 safe_area_changed: InsetsManagerBase::new(),
155 state_changed: InputPaneBase::new(),
156 });
157
158 this.set_display_edge_to_edge_preference(false);
159 this
160 }
161
162 fn render_scaling(&self) -> f64 {
163 self.top_level.upgrade().map_or(1.0, |top_level| top_level.top_level_render_scaling())
164 }
165
166 fn client_size(&self) -> Size {
167 self.top_level.upgrade().map_or(Size::default(), |top_level| top_level.top_level_client_size())
168 }
169
170 fn set_state(&self, value: InputPaneState) {
171 let old_state = self.state.replace(value);
172
173 if old_state != value && self.window.sdk_int() <= Q {
174 let current_rect = self.occluded_rect();
175 self.notify_state_changed(value, Some(self.previous_rect.get()), current_rect, Duration::ZERO, None);
176 self.previous_rect.set(current_rect);
177 }
178 }
179
180 fn update_display_edge_to_egde_state(&self) {
181 if self.is_display_edge_to_edge_forced {
182 self.displays_edge_to_edge.set(true);
183 return;
184 }
185
186 let preference = self.display_edge_to_edge_preference.get();
187 self.displays_edge_to_edge.set(preference);
188
189 self.window.set_layout_in_display_cutout_mode(preference);
190 self.window.set_decor_fits_system_windows(!preference);
191
192 if preference {
193 self.window.add_translucent_bars();
194 } else {
195 self.set_system_bar_color(self.system_bar_color.get());
196 }
197 }
198
199 pub(crate) fn on_apply_window_insets(&self, has_insets: bool, ime_visible: bool, ime_bottom: i32) {
202 self.notify_safe_area_changed(self.safe_area_padding());
203
204 if self.previous_rect.get() == Rect::default() {
205 self.previous_rect.set(self.occluded_rect());
206 }
207
208 self.set_state(if has_insets && ime_visible { InputPaneState::Open } else { InputPaneState::Closed });
209
210 if self.window.sdk_int() == R {
212 let ime_inset = has_insets.then_some(ime_bottom);
213 if self.previous_ime_inset.get().is_none() {
214 self.previous_ime_inset.set(ime_inset);
215 }
216 if ime_inset.unwrap_or(0) != self.previous_ime_inset.get().unwrap_or(0) {
217 self.notify_state_changed(
218 self.state.get(),
219 Some(self.previous_rect.get()),
220 self.occluded_rect(),
221 Duration::ZERO,
222 None,
223 );
224 }
225 self.previous_ime_inset.set(ime_inset);
226 }
227 }
228
229 pub(crate) fn set_default_system_light_mode(&self, is_light_mode: bool) {
230 self.is_default_system_bar_light_theme.set(Some(is_light_mode));
231 }
232
233 fn notify_safe_area_changed(&self, safe_area_padding: Thickness) {
234 self.safe_area_changed.on_safe_area_changed(SafeAreaChangedArgs::new(safe_area_padding));
235 }
236
237 fn notify_state_changed(
238 &self,
239 new_state: InputPaneState,
240 start_rect: Option<Rect>,
241 end_rect: Rect,
242 animation_duration: Duration,
243 easing: Option<Rc<dyn IEasing>>,
244 ) {
245 self.state_changed.on_state_changed(InputPaneStateEventArgs::with_animation(
246 new_state,
247 start_rect,
248 end_rect,
249 animation_duration,
250 easing,
251 ));
252 }
253
254 pub(crate) fn on_global_layout(&self) {
257 self.notify_safe_area_changed(self.safe_area_padding());
258
259 if self.uses_legacy_layouts {
260 let insets = self.window.root_insets();
261 self.set_state(if insets.is_some_and(|insets| insets.ime_visible) {
262 InputPaneState::Open
263 } else {
264 InputPaneState::Closed
265 });
266 }
267 }
268
269 pub(crate) fn on_insets_animation_start(
275 &self,
276 lower_bound_bottom: i32,
277 upper_bound_bottom: i32,
278 duration_millis: i64,
279 easing: Option<Rc<dyn IEasing>>,
280 ) {
281 if let Some(insets) = self.window.root_insets() {
282 let navbar_inset = insets.navigation_bars_bottom;
283 let client_size = self.client_size();
284 let bottom = self.safe_area_padding().bottom;
285 let rect_of = |bound_bottom: i32| {
286 let height = f64::from((((bound_bottom - navbar_inset) as f64 / self.render_scaling()) as f32).max(0.0));
287 Rect::new(0.0, client_size.height - bottom - height, client_size.width, height)
288 };
289 let upper_rect = rect_of(lower_bound_bottom);
290 let lower_rect = rect_of(upper_bound_bottom);
291
292 let duration = Duration::from_millis(u64::try_from(duration_millis).unwrap_or(0));
293
294 let is_opening = self.state.get() == InputPaneState::Open;
295 self.notify_state_changed(
296 self.state.get(),
297 Some(if is_opening { upper_rect } else { lower_rect }),
298 if is_opening { lower_rect } else { upper_rect },
299 duration,
300 easing,
301 );
302 }
303 }
304
305 pub fn system_bar_theme(&self) -> Option<SystemBarTheme> {
307 Some(if self.window.appearance_light_status_bars() { SystemBarTheme::Light } else { SystemBarTheme::Dark })
308 }
309
310 pub fn set_system_bar_theme(&self, value: Option<SystemBarTheme>) {
313 self.status_bar_theme.set(value);
314
315 if self.is_default_system_bar_light_theme.get().is_none() {
316 self.is_default_system_bar_light_theme.set(Some(self.window.appearance_light_status_bars()));
317 }
318
319 let value = value.unwrap_or(if self.is_default_system_bar_light_theme.get() == Some(true) {
320 SystemBarTheme::Light
321 } else {
322 SystemBarTheme::Dark
323 });
324
325 self.window.set_appearance_light_bars(value == SystemBarTheme::Light);
326 }
327
328 pub(crate) fn apply_status_bar_state(&self) {
329 self.set_is_system_bar_visible(self.system_ui_visibility.get());
330 self.set_system_bar_theme(self.status_bar_theme.get());
331 self.set_system_bar_color(self.system_bar_color.get());
332 }
333}
334
335pub(crate) fn safe_area_padding_of(insets: &RootInsets, displays_edge_to_edge: bool, render_scaling: f64) -> Thickness {
338 if !displays_edge_to_edge {
339 return Thickness::default();
340 }
341 Thickness::new(
342 f64::from(insets.left) / render_scaling,
343 f64::from(insets.top) / render_scaling,
344 f64::from(insets.right) / render_scaling,
345 f64::from(insets.bottom) / render_scaling,
346 )
347}
348
349pub(crate) fn occluded_rect_of(
351 insets: &RootInsets,
352 render_scaling: f64,
353 client_size: Size,
354 safe_area_bottom: f64,
355) -> Rect {
356 let navbar_inset = insets.navigation_bars_bottom;
357 let ime_inset = insets.ime_bottom;
358
359 let height = f64::from((((ime_inset - navbar_inset) as f64 / render_scaling) as f32).max(0.0));
360
361 Rect::new(0.0, client_size.height - safe_area_bottom - height, client_size.width, height)
362}
363
364impl IInsetsManager for AndroidInsetsManager {
365 fn is_system_bar_visible(&self) -> Option<bool> {
366 if !self.window.has_window() {
367 return Some(true);
368 }
369 self.window.root_insets().map(|insets| insets.system_bars_visible)
370 }
371
372 fn set_is_system_bar_visible(&self, value: Option<bool>) {
373 self.system_ui_visibility.set(value);
374
375 self.window.set_system_bars_visible(value.unwrap_or(true));
376 }
377
378 fn display_edge_to_edge_preference(&self) -> bool {
379 self.display_edge_to_edge_preference.get()
380 }
381
382 fn set_display_edge_to_edge_preference(&self, value: bool) {
383 self.display_edge_to_edge_preference.set(value);
384
385 self.update_display_edge_to_egde_state();
386 }
387
388 fn displays_edge_to_edge(&self) -> bool {
389 self.displays_edge_to_edge.get()
390 }
391
392 fn safe_area_padding(&self) -> Thickness {
393 match self.window.root_insets() {
394 Some(insets) => safe_area_padding_of(&insets, self.displays_edge_to_edge(), self.render_scaling()),
395 None => Thickness::default(),
396 }
397 }
398
399 fn system_bar_color(&self) -> Option<Color> {
400 self.system_bar_color.get()
401 }
402
403 fn set_system_bar_color(&self, value: Option<Color>) {
404 self.system_bar_color.set(value);
405
406 if self.is_display_edge_to_edge_forced {
407 if self.window.sdk_int() >= 36 {
409 self.window.set_navigation_bar_contrast_enforced(false);
410 } else if self.window.sdk_int() >= 35 {
411 self.window.set_navigation_bar_contrast_enforced(value != Some(Colors::TRANSPARENT));
412 }
413 return;
414 }
415
416 if let Some(color) = value {
417 if !self.displays_edge_to_edge.get() && self.window.has_window() {
418 self.window.set_system_bar_color(color);
419 }
420 }
421 }
422
423 fn safe_area_changed(&self, handler: Rc<dyn Fn(&SafeAreaChangedArgs)>) -> Rc<dyn IDisposable> {
424 self.safe_area_changed.safe_area_changed(handler)
425 }
426}
427
428impl IInputPane for AndroidInsetsManager {
429 fn state(&self) -> InputPaneState {
430 self.state.get()
431 }
432
433 fn occluded_rect(&self) -> Rect {
434 match self.window.root_insets() {
435 Some(insets) => {
436 occluded_rect_of(&insets, self.render_scaling(), self.client_size(), self.safe_area_padding().bottom)
437 }
438 None => Rect::default(),
439 }
440 }
441
442 fn state_changed(&self, handler: Rc<dyn Fn(&InputPaneStateEventArgs)>) -> Rc<dyn IDisposable> {
443 self.state_changed.state_changed(handler)
444 }
445}
446
447#[cfg(target_os = "android")]
448pub(crate) use imp::{ActivityInsetsWindow, AnimationEasing};
449
450#[cfg(target_os = "android")]
451mod imp {
452 use super::{IInsetsWindow, RootInsets};
453 use crate::interop::java::{
454 call_object, call_static_boolean, call_static_object, call_static_void, int_array_of, JavaClass, JavaObject,
455 JavaValue,
456 };
457 use crate::interop::natives::{sdk_int, PLATFORM_HELPER};
458 use ferroui_base::animation::easings::{IEasing, SharedEasing};
459 use ferroui_base::media::Color;
460 use std::sync::Arc;
461
462 pub(crate) struct AnimationEasing {
465 interpolator: JavaObject,
466 }
467
468 impl AnimationEasing {
469 pub fn new(interpolator: JavaObject) -> Self {
470 Self { interpolator }
471 }
472 }
473
474 impl IEasing for AnimationEasing {
475 fn ease(&self, progress: f64) -> f64 {
476 f64::from(crate::interop::java::call_float(
477 &self.interpolator,
478 "getInterpolation",
479 "(F)F",
480 &[JavaValue::Float(progress as f32)],
481 ))
482 }
483
484 fn to_shared(&self) -> Arc<SharedEasing> {
485 Arc::new(AnimationEasing { interpolator: self.interpolator.clone() })
486 }
487 }
488
489 pub(crate) struct ActivityInsetsWindow {
491 activity: JavaObject,
492 }
493
494 impl ActivityInsetsWindow {
495 pub fn new(activity: JavaObject, handle: i64) -> Self {
498 call_static_void(
499 &JavaClass::find(PLATFORM_HELPER),
500 "setInsetsListener",
501 "(Landroid/app/Activity;J)V",
502 &[JavaValue::Object(Some(&activity)), JavaValue::Long(handle)],
503 );
504 Self { activity }
505 }
506
507 fn call(&self, name: &str, value: bool) {
508 call_static_void(
509 &JavaClass::find(PLATFORM_HELPER),
510 name,
511 "(Landroid/app/Activity;Z)V",
512 &[JavaValue::Object(Some(&self.activity)), JavaValue::Boolean(value)],
513 );
514 }
515 }
516
517 impl IInsetsWindow for ActivityInsetsWindow {
518 fn sdk_int(&self) -> i32 {
519 sdk_int()
520 }
521
522 fn has_window(&self) -> bool {
523 call_object(&self.activity, "getWindow", "()Landroid/view/Window;", &[]).is_some()
524 }
525
526 fn root_insets(&self) -> Option<RootInsets> {
527 let values = call_static_object(
528 &JavaClass::find(PLATFORM_HELPER),
529 "getRootInsets",
530 "(Landroid/app/Activity;)[I",
531 &[JavaValue::Object(Some(&self.activity))],
532 )?;
533 RootInsets::from_array(&int_array_of(&values))
534 }
535
536 fn is_display_edge_to_edge_forced(&self) -> bool {
537 call_static_boolean(
538 &JavaClass::find(PLATFORM_HELPER),
539 "isDisplayEdgeToEdgeForced",
540 "(Landroid/app/Activity;)Z",
541 &[JavaValue::Object(Some(&self.activity))],
542 )
543 }
544
545 fn set_layout_in_display_cutout_mode(&self, short_edges: bool) {
546 self.call("setLayoutInDisplayCutoutMode", short_edges);
547 }
548
549 fn set_decor_fits_system_windows(&self, decor_fits_system_windows: bool) {
550 self.call("setDecorFitsSystemWindows", decor_fits_system_windows);
551 }
552
553 fn add_translucent_bars(&self) {
554 self.call("setTranslucentBars", true);
555 }
556
557 fn set_system_bar_color(&self, color: Color) {
558 call_static_void(
559 &JavaClass::find(PLATFORM_HELPER),
560 "setSystemBarColor",
561 "(Landroid/app/Activity;I)V",
562 &[JavaValue::Object(Some(&self.activity)), JavaValue::Int(color.to_uint32() as i32)],
563 );
564 }
565
566 fn set_navigation_bar_contrast_enforced(&self, enforced: bool) {
567 self.call("setNavigationBarContrastEnforced", enforced);
568 }
569
570 fn appearance_light_status_bars(&self) -> bool {
571 call_static_boolean(
572 &JavaClass::find(PLATFORM_HELPER),
573 "getAppearanceLightStatusBars",
574 "(Landroid/app/Activity;)Z",
575 &[JavaValue::Object(Some(&self.activity))],
576 )
577 }
578
579 fn set_appearance_light_bars(&self, light: bool) {
580 self.call("setAppearanceLightBars", light);
581 }
582
583 fn set_system_bars_visible(&self, visible: bool) {
584 self.call("setSystemBarsVisible", visible);
585 }
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
593 use std::cell::RefCell;
594
595 #[derive(Default)]
596 struct WindowState {
597 sdk_int: Cell<i32>,
598 forced: Cell<bool>,
599 insets: Cell<Option<RootInsets>>,
600 light_status_bars: Cell<bool>,
601 calls: RefCell<Vec<String>>,
602 }
603
604 struct Window(Rc<WindowState>);
605
606 impl IInsetsWindow for Window {
607 fn sdk_int(&self) -> i32 {
608 self.0.sdk_int.get()
609 }
610
611 fn has_window(&self) -> bool {
612 true
613 }
614
615 fn root_insets(&self) -> Option<RootInsets> {
616 self.0.insets.get()
617 }
618
619 fn is_display_edge_to_edge_forced(&self) -> bool {
620 self.0.forced.get()
621 }
622
623 fn set_layout_in_display_cutout_mode(&self, short_edges: bool) {
624 self.0.calls.borrow_mut().push(format!("cutout {short_edges}"));
625 }
626
627 fn set_decor_fits_system_windows(&self, decor_fits_system_windows: bool) {
628 self.0.calls.borrow_mut().push(format!("fits {decor_fits_system_windows}"));
629 }
630
631 fn add_translucent_bars(&self) {
632 self.0.calls.borrow_mut().push("translucent".to_string());
633 }
634
635 fn set_system_bar_color(&self, color: Color) {
636 self.0.calls.borrow_mut().push(format!("color {:08x}", color.to_uint32()));
637 }
638
639 fn set_navigation_bar_contrast_enforced(&self, enforced: bool) {
640 self.0.calls.borrow_mut().push(format!("contrast {enforced}"));
641 }
642
643 fn appearance_light_status_bars(&self) -> bool {
644 self.0.light_status_bars.get()
645 }
646
647 fn set_appearance_light_bars(&self, light: bool) {
648 self.0.light_status_bars.set(light);
649 self.0.calls.borrow_mut().push(format!("light {light}"));
650 }
651
652 fn set_system_bars_visible(&self, visible: bool) {
653 self.0.calls.borrow_mut().push(format!("visible {visible}"));
654 }
655 }
656
657 struct TopLevel;
658
659 impl IInsetsTopLevel for TopLevel {
660 fn top_level_render_scaling(&self) -> f64 {
661 2.0
662 }
663
664 fn top_level_client_size(&self) -> Size {
665 Size::new(540.0, 1200.0)
666 }
667 }
668
669 fn manager(sdk_int: i32, forced: bool) -> (Rc<AndroidInsetsManager>, Rc<WindowState>, Rc<dyn IInsetsTopLevel>) {
670 let state = Rc::new(WindowState::default());
671 state.sdk_int.set(sdk_int);
672 state.forced.set(forced);
673 let top_level: Rc<dyn IInsetsTopLevel> = Rc::new(TopLevel);
674 let manager = AndroidInsetsManager::new(Box::new(Window(state.clone())), Rc::downgrade(&top_level));
675 (manager, state, top_level)
676 }
677
678 const BARS: RootInsets = RootInsets {
679 left: 0,
680 top: 100,
681 right: 0,
682 bottom: 48,
683 navigation_bars_bottom: 48,
684 ime_bottom: 0,
685 ime_visible: false,
686 system_bars_visible: true,
687 };
688
689 #[test]
690 fn the_values_of_the_java_layer_are_read() {
691 assert_eq!(RootInsets::from_array(&[0, 100, 0, 48, 48, 0, 0, 1]), Some(BARS));
692 assert_eq!(RootInsets::from_array(&[0, 100]), None);
693 }
694
695 #[test]
696 fn a_window_that_is_not_edge_to_edge_has_no_safe_area() {
697 let (manager, state, _top_level) = manager(34, false);
698 state.insets.set(Some(BARS));
699
700 assert!(!manager.displays_edge_to_edge());
701 assert_eq!(manager.safe_area_padding(), Thickness::default());
702 assert_eq!(*state.calls.borrow(), ["cutout false", "fits true"]);
704 }
705
706 #[test]
707 fn the_preference_displays_edge_to_edge_and_the_safe_area_is_the_bars() {
708 let (manager, state, _top_level) = manager(34, false);
709 state.insets.set(Some(BARS));
710 state.calls.borrow_mut().clear();
711
712 manager.set_display_edge_to_edge_preference(true);
713
714 assert!(manager.displays_edge_to_edge());
715 assert_eq!(manager.safe_area_padding(), Thickness::new(0.0, 50.0, 0.0, 24.0));
716 assert_eq!(*state.calls.borrow(), ["cutout true", "fits false", "translucent"]);
717 }
718
719 #[test]
720 fn a_system_that_forces_edge_to_edge_ignores_the_preference() {
721 let (manager, state, _top_level) = manager(36, true);
722 state.insets.set(Some(BARS));
723
724 assert!(manager.displays_edge_to_edge());
725 assert!(!manager.display_edge_to_edge_preference());
726 assert_eq!(manager.safe_area_padding(), Thickness::new(0.0, 50.0, 0.0, 24.0));
727 assert!(state.calls.borrow().is_empty());
728
729 manager.set_system_bar_color(Some(Color::from_argb(255, 1, 2, 3)));
731 assert_eq!(manager.system_bar_color(), Some(Color::from_argb(255, 1, 2, 3)));
732 assert_eq!(*state.calls.borrow(), ["contrast false"]);
733 }
734
735 #[test]
736 fn on_api_35_the_contrast_is_enforced_unless_the_colour_is_transparent() {
737 let (manager, state, _top_level) = manager(35, true);
738
739 manager.set_system_bar_color(Some(Colors::TRANSPARENT));
740 manager.set_system_bar_color(None);
741
742 assert_eq!(*state.calls.borrow(), ["contrast false", "contrast true"]);
743 }
744
745 #[test]
746 fn a_colour_is_set_on_a_window_that_is_not_edge_to_edge() {
747 let (manager, state, _top_level) = manager(34, false);
748 state.calls.borrow_mut().clear();
749
750 manager.set_system_bar_color(Some(Color::from_argb(255, 0x10, 0x20, 0x30)));
751 assert_eq!(*state.calls.borrow(), ["color ff102030"]);
752
753 manager.set_display_edge_to_edge_preference(true);
754 state.calls.borrow_mut().clear();
755 manager.set_system_bar_color(Some(Color::from_argb(255, 1, 1, 1)));
756 assert!(state.calls.borrow().is_empty());
757 }
758
759 #[test]
760 fn the_occluded_rectangle_is_the_input_method_above_the_navigation_bar() {
761 let (manager, state, _top_level) = manager(36, true);
762 let mut insets = BARS;
763 insets.ime_bottom = 848;
764 insets.ime_visible = true;
765 insets.bottom = 48;
766 state.insets.set(Some(insets));
767
768 assert_eq!(manager.occluded_rect(), Rect::new(0.0, 1200.0 - 24.0 - 400.0, 540.0, 400.0));
770
771 state.insets.set(None);
772 assert_eq!(manager.occluded_rect(), Rect::default());
773 }
774
775 #[test]
776 fn applied_insets_report_the_safe_area_and_the_state_of_the_input_pane() {
777 let _scope = ferroui_base::threading::Dispatcher::unit_test_scope();
778 let (manager, state, _top_level) = manager(29, false);
780 manager.set_display_edge_to_edge_preference(true);
781 state.insets.set(Some(BARS));
782 let safe_areas = Rc::new(RefCell::new(Vec::new()));
783 let states = Rc::new(RefCell::new(Vec::new()));
784 let _ = manager.safe_area_changed(Rc::new({
785 let safe_areas = safe_areas.clone();
786 move |e: &SafeAreaChangedArgs| safe_areas.borrow_mut().push(e.safe_area_padding())
787 }));
788 let _ = manager.state_changed(Rc::new({
789 let states = states.clone();
790 move |e: &InputPaneStateEventArgs| states.borrow_mut().push((e.new_state(), e.end_rect()))
791 }));
792
793 manager.on_apply_window_insets(true, false, 0);
794 assert_eq!(*safe_areas.borrow(), [Thickness::new(0.0, 50.0, 0.0, 24.0)]);
795 assert_eq!(manager.state(), InputPaneState::Closed);
796 assert!(states.borrow().is_empty());
797
798 let mut insets = BARS;
799 insets.ime_bottom = 848;
800 insets.ime_visible = true;
801 state.insets.set(Some(insets));
802 manager.on_apply_window_insets(true, true, 848);
803
804 assert_eq!(manager.state(), InputPaneState::Open);
805 assert_eq!(*states.borrow(), [(InputPaneState::Open, Rect::new(0.0, 776.0, 540.0, 400.0))]);
806 assert_eq!(safe_areas.borrow().len(), 2);
807 }
808
809 #[test]
810 fn on_android_11_a_change_of_the_inset_of_the_input_method_is_reported() {
811 let _scope = ferroui_base::threading::Dispatcher::unit_test_scope();
812 let (manager, state, _top_level) = manager(30, false);
813 state.insets.set(Some(BARS));
814 let states = Rc::new(RefCell::new(Vec::new()));
815 let _ = manager.state_changed(Rc::new({
816 let states = states.clone();
817 move |e: &InputPaneStateEventArgs| states.borrow_mut().push(e.new_state())
818 }));
819
820 manager.on_apply_window_insets(true, false, 0);
821 assert!(states.borrow().is_empty());
822 manager.on_apply_window_insets(true, true, 700);
823 manager.on_apply_window_insets(true, true, 700);
824
825 assert_eq!(*states.borrow(), [InputPaneState::Open]);
826 }
827
828 #[test]
829 fn the_theme_of_the_bars_falls_back_to_the_theme_of_the_system() {
830 let (manager, state, _top_level) = manager(34, false);
831 state.calls.borrow_mut().clear();
832
833 manager.set_default_system_light_mode(true);
834 manager.set_system_bar_theme(Some(SystemBarTheme::Dark));
835 assert_eq!(manager.system_bar_theme(), Some(SystemBarTheme::Dark));
836 manager.set_system_bar_theme(None);
837 assert_eq!(manager.system_bar_theme(), Some(SystemBarTheme::Light));
838 assert_eq!(*state.calls.borrow(), ["light false", "light true"]);
839 }
840
841 #[test]
842 fn the_visibility_of_the_bars_is_asked_of_the_insets_and_set_on_the_window() {
843 let (manager, state, _top_level) = manager(34, false);
844 state.calls.borrow_mut().clear();
845
846 assert_eq!(manager.is_system_bar_visible(), None);
847 state.insets.set(Some(BARS));
848 assert_eq!(manager.is_system_bar_visible(), Some(true));
849
850 manager.set_is_system_bar_visible(Some(false));
851 manager.set_is_system_bar_visible(None);
852 assert_eq!(*state.calls.borrow(), ["visible false", "visible true"]);
853 }
854}
855
856#[cfg(test)]
857mod input_pane_tests {
858 use super::*;
860 use std::cell::RefCell;
861
862 struct Window {
863 sdk_int: i32,
864 insets: Rc<Cell<Option<RootInsets>>>,
865 }
866
867 impl IInsetsWindow for Window {
868 fn sdk_int(&self) -> i32 {
869 self.sdk_int
870 }
871
872 fn has_window(&self) -> bool {
873 true
874 }
875
876 fn root_insets(&self) -> Option<RootInsets> {
877 self.insets.get()
878 }
879
880 fn is_display_edge_to_edge_forced(&self) -> bool {
881 true
882 }
883
884 fn set_layout_in_display_cutout_mode(&self, _short_edges: bool) {}
885 fn set_decor_fits_system_windows(&self, _decor_fits_system_windows: bool) {}
886 fn add_translucent_bars(&self) {}
887 fn set_system_bar_color(&self, _color: Color) {}
888 fn set_navigation_bar_contrast_enforced(&self, _enforced: bool) {}
889
890 fn appearance_light_status_bars(&self) -> bool {
891 true
892 }
893
894 fn set_appearance_light_bars(&self, _light: bool) {}
895 fn set_system_bars_visible(&self, _visible: bool) {}
896 }
897
898 struct TopLevel;
899
900 impl IInsetsTopLevel for TopLevel {
901 fn top_level_render_scaling(&self) -> f64 {
902 2.0
903 }
904
905 fn top_level_client_size(&self) -> Size {
906 Size::new(540.0, 1200.0)
907 }
908 }
909
910 type Seen = Rc<RefCell<Vec<(InputPaneState, Option<Rect>, Rect, Duration, bool)>>>;
911
912 fn manager(sdk_int: i32) -> (Rc<AndroidInsetsManager>, Rc<Cell<Option<RootInsets>>>, Seen, Rc<dyn IInsetsTopLevel>) {
913 let insets = Rc::new(Cell::new(Some(bars(0, false))));
914 let top_level: Rc<dyn IInsetsTopLevel> = Rc::new(TopLevel);
915 let manager =
916 AndroidInsetsManager::new(Box::new(Window { sdk_int, insets: insets.clone() }), Rc::downgrade(&top_level));
917 let seen: Seen = Rc::new(RefCell::new(Vec::new()));
918 std::mem::forget(manager.state_changed(Rc::new({
919 let seen = seen.clone();
920 move |e: &InputPaneStateEventArgs| {
921 seen.borrow_mut().push((
922 e.new_state(),
923 e.start_rect(),
924 e.end_rect(),
925 e.animation_duration(),
926 e.easing().is_some(),
927 ));
928 }
929 })));
930 (manager, insets, seen, top_level)
931 }
932
933 fn bars(ime_bottom: i32, ime_visible: bool) -> RootInsets {
935 RootInsets {
936 left: 0,
937 top: 100,
938 right: 0,
939 bottom: 48,
940 navigation_bars_bottom: 48,
941 ime_bottom,
942 ime_visible,
943 system_bars_visible: true,
944 }
945 }
946
947 #[test]
948 fn from_api_30_the_animation_of_the_insets_reports_the_input_pane() {
949 let _scope = ferroui_base::threading::Dispatcher::unit_test_scope();
950 let (manager, insets, seen, _top_level) = manager(36);
951
952 insets.set(Some(bars(848, true)));
954 manager.on_apply_window_insets(true, true, 848);
955 assert_eq!(manager.state(), InputPaneState::Open);
956 assert!(seen.borrow().is_empty());
957
958 manager.on_insets_animation_start(0, 848, 285, None);
960 assert_eq!(
963 *seen.borrow(),
964 vec![(
965 InputPaneState::Open,
966 Some(Rect::new(0.0, 1176.0, 540.0, 0.0)),
967 Rect::new(0.0, 776.0, 540.0, 400.0),
968 Duration::from_millis(285),
969 false
970 )]
971 );
972 assert_eq!(manager.occluded_rect(), Rect::new(0.0, 776.0, 540.0, 400.0));
973
974 seen.borrow_mut().clear();
976 insets.set(Some(bars(0, false)));
977 manager.on_apply_window_insets(true, false, 0);
978 manager.on_insets_animation_start(0, 848, 200, None);
979 assert_eq!(
980 *seen.borrow(),
981 vec![(
982 InputPaneState::Closed,
983 Some(Rect::new(0.0, 776.0, 540.0, 400.0)),
984 Rect::new(0.0, 1176.0, 540.0, 0.0),
985 Duration::from_millis(200),
986 false
987 )]
988 );
989 }
990
991 #[test]
992 fn below_api_30_the_global_layout_reports_the_input_pane_at_once() {
993 let _scope = ferroui_base::threading::Dispatcher::unit_test_scope();
994 let (manager, insets, seen, _top_level) = manager(29);
995
996 manager.on_global_layout();
997 assert!(seen.borrow().is_empty());
998
999 insets.set(Some(bars(848, true)));
1000 manager.on_global_layout();
1001 assert_eq!(manager.state(), InputPaneState::Open);
1002 assert_eq!(seen.borrow().len(), 1);
1003 let (state, _, end, duration, easing) = seen.borrow()[0];
1004 assert_eq!((state, end, duration, easing), (InputPaneState::Open, Rect::new(0.0, 776.0, 540.0, 400.0), Duration::ZERO, false));
1005
1006 manager.on_global_layout();
1008 assert_eq!(seen.borrow().len(), 1);
1009 }
1010
1011 #[test]
1012 fn from_api_30_the_global_layout_does_not_touch_the_state() {
1013 let _scope = ferroui_base::threading::Dispatcher::unit_test_scope();
1014 let (manager, insets, seen, _top_level) = manager(36);
1015 insets.set(Some(bars(848, true)));
1016 manager.on_global_layout();
1017 assert_eq!(manager.state(), InputPaneState::Closed);
1018 assert!(seen.borrow().is_empty());
1019 }
1020}