ribir_widgets 0.4.0-alpha.65

A non-intrusive declarative GUI framework, to build modern native/wasm cross-platform applications.
Documentation
use ribir_core::prelude::*;

use crate::overlay::{AutoClosePolicy, Overlay, OverlayStyle};

class_names! {
  #[doc = "Class name for the widgets tooltip presentation shell"]
  TOOLTIP_SHELL,
}

struct OverlayTooltip;

impl CustomTooltip for OverlayTooltip {
  fn spawn_bubble(
    &self, bubble: Widget<'static>, visible: Stateful<bool>, host_track: TrackId,
  ) -> Box<dyn FnOnce()> {
    let reusable = Reusable::new(bubble);
    let overlay = Overlay::new(
      move || {
        let reusable = reusable.clone();
        class! { class: TOOLTIP_SHELL, @ { reusable.get_widget() } }
      },
      OverlayStyle { auto_close_policy: AutoClosePolicy::NOT_AUTO_CLOSE, mask: None },
    );

    let sub = watch!(*$read(visible))
      .distinct_until_changed()
      .subscribe({
        let overlay = overlay.clone();
        let wnd = BuildCtx::get().window();
        move |visible| {
          if visible && host_track.get().is_some() {
            overlay.show(wnd.clone());
          } else {
            overlay.close();
          }
        }
      });

    Box::new(move || {
      overlay.close();
      sub.unsubscribe();
    })
  }
}

pub fn default_tooltip_provider() -> Provider {
  Provider::new(Box::new(OverlayTooltip) as Box<dyn CustomTooltip>)
}

#[cfg(test)]
mod tests {
  use ribir_core::{reset_test_env, test_helper::*, window::WindowFlags};

  use super::*;
  use crate::prelude::{AnimatedPresence, Interruption, cases};

  const TOOLTIP_SHOW_DELAY: Duration = Duration::from_millis(500);
  const TOOLTIP_HIDE_DELAY: Duration = Duration::from_millis(150);
  const HOST_POINT: Point = Point::new(10., 10.);
  const OUTSIDE_POINT: Point = Point::new(100., 70.);
  const OFFSET_HOST_POINT: Point = Point::new(110., 90.);

  fn wait_for_tooltip_show_delay() {
    AppCtx::run_until(AppCtx::timer(TOOLTIP_SHOW_DELAY + Duration::from_millis(20)));
    AppCtx::run_until_stalled();
  }

  fn wait_for_tooltip_hide_delay() {
    AppCtx::run_until(AppCtx::timer(TOOLTIP_HIDE_DELAY + Duration::from_millis(20)));
    AppCtx::run_until_stalled();
  }

  fn wait_for(duration: Duration) {
    AppCtx::run_until(AppCtx::timer(duration));
    AppCtx::run_until_stalled();
  }

  fn root_children_count(wnd: &TestWindow) -> usize { wnd.children_count(wnd.root()) }

  fn move_cursor_and_draw(wnd: &TestWindow, point: Point) {
    wnd.process_cursor_move(point);
    wnd.draw_frame();
  }

  fn hover_and_show(wnd: &TestWindow, point: Point) -> usize {
    move_cursor_and_draw(wnd, point);
    wait_for_tooltip_show_delay();
    wnd.draw_frame();
    root_children_count(wnd)
  }

  fn overlay_root(wnd: &TestWindow) -> WidgetId {
    wnd
      .children(wnd.root())
      .last()
      .expect("overlay tooltip should be mounted at window root")
  }

  fn overlay_rect(wnd: &TestWindow) -> (Point, Size) {
    let overlay_root = overlay_root(wnd);
    let pos = wnd
      .widget_pos(overlay_root)
      .expect("overlay tooltip should have a layout position");
    let size = wnd
      .widget_size(overlay_root)
      .expect("overlay tooltip should have a layout size");
    (pos, size)
  }

  fn overlay_center_global(wnd: &TestWindow) -> Point {
    let overlay_root = overlay_root(wnd);
    let global = wnd.map_to_global(Point::zero(), overlay_root);
    let size = wnd
      .widget_size(overlay_root)
      .expect("overlay tooltip should have a layout size");
    Point::new(global.x + size.width / 2., global.y + size.height / 2.)
  }

  fn install_animated_tooltip_shell_theme() {
    let mut theme = Theme::default();
    theme.classes.insert(TOOLTIP_SHELL, |w| {
      fn_widget! {
        let mut w = FatObj::new(w);
        let opacity = w.opacity();

        @AnimatedPresence {
          cases: cases! {
            state: opacity,
            true => 1.0,
            false => 0.0,
          },
          enter: EasingTransition {
            easing: easing::CubicBezierEasing::new(0., 0., 0.2, 1.),
            duration: Duration::from_millis(150),
          },
          leave: EasingTransition {
            easing: easing::CubicBezierEasing::new(0.4, 0., 1., 1.),
            duration: Duration::from_millis(150),
          },
          interruption: Interruption::Fluid,
          @ { w }
        }
      }
      .into_widget()
    });
    AppCtx::set_app_theme(theme);
  }

  #[test]
  fn custom_tooltip_provider_mounts_on_hover() {
    reset_test_env!();

    let wnd = TestWindow::new_with_size(
      fn_widget! {
        @Providers {
          providers: [default_tooltip_provider()],
          @MockBox {
            size: Size::new(40., 20.),
            tooltip: "tip",
          }
        }
      },
      Size::new(120., 80.),
    );
    wnd.draw_frame();

    let before = root_children_count(&wnd);
    let shown = hover_and_show(&wnd, HOST_POINT);

    assert!(shown > before, "custom tooltip should mount overlay content when hovered");

    move_cursor_and_draw(&wnd, OUTSIDE_POINT);
    assert_eq!(root_children_count(&wnd), shown);
    wait_for_tooltip_hide_delay();
    wnd.draw_frame();
    assert_eq!(root_children_count(&wnd), before);
  }

  #[test]
  fn custom_tooltip_provider_supports_widget_content() {
    reset_test_env!();

    let tooltip = Tooltip::from_widget(fn_widget! {
      @MockBox { size: Size::new(60., 24.) }
    });
    let tooltip_in_widget = tooltip.clone();
    let wnd = TestWindow::new_with_size(
      fn_widget! {
        let tooltip = tooltip_in_widget.clone();
        @Providers {
          providers: [default_tooltip_provider()],
          @MockBox {
            size: Size::new(40., 20.),
            tooltip,
          }
        }
      },
      Size::new(160., 120.),
    );
    wnd.draw_frame();

    hover_and_show(&wnd, HOST_POINT);

    let overlay_root = overlay_root(&wnd);
    let overlay_bubble = wnd
      .children(overlay_root)
      .last()
      .unwrap_or(overlay_root);
    let overlay_size = wnd
      .widget_size(overlay_bubble)
      .expect("overlay tooltip should have a layout size");

    assert_eq!(overlay_size, Size::new(60., 24.));
  }

  #[test]
  fn custom_tooltip_provider_positions_overlay_relative_to_host() {
    reset_test_env!();

    let wnd = TestWindow::new_with_size(
      fn_widget! {
        @Providers {
          providers: [default_tooltip_provider()],
          @MockBox {
            size: Size::new(40., 20.),
            x: 100.,
            y: 80.,
            tooltip: "tip",
          }
        }
      },
      Size::new(240., 200.),
    );
    wnd.draw_frame();

    hover_and_show(&wnd, OFFSET_HOST_POINT);

    let (overlay_pos, overlay_size) = overlay_rect(&wnd);

    let host_center_x = 100. + 20.;
    let bubble_center_x = overlay_pos.x + overlay_size.width / 2.;
    assert!((bubble_center_x - host_center_x).abs() < 1.0);
    assert!(overlay_pos.y < 80.);
    assert!((overlay_pos.y + overlay_size.height - 80.).abs() < 1.0);
  }

  #[test]
  fn custom_tooltip_stays_visible_while_hovering_overlay() {
    reset_test_env!();

    let wnd = TestWindow::new_with_size(
      fn_widget! {
        @Providers {
          providers: [default_tooltip_provider()],
          @MockBox {
            size: Size::new(40., 20.),
            x: 100.,
            y: 80.,
            tooltip: "tip",
          }
        }
      },
      Size::new(240., 200.),
    );
    wnd.draw_frame();

    let before = root_children_count(&wnd);
    let shown = hover_and_show(&wnd, OFFSET_HOST_POINT);
    assert!(shown > before, "tooltip should mount while host is hovered");

    wnd.process_cursor_move(overlay_center_global(&wnd));
    wnd.draw_frame();

    assert_eq!(
      root_children_count(&wnd),
      shown,
      "moving onto tooltip content should keep tooltip visible"
    );

    move_cursor_and_draw(&wnd, HOST_POINT);
    assert_eq!(root_children_count(&wnd), shown);
    wait_for_tooltip_hide_delay();
    wnd.draw_frame();
    assert_eq!(root_children_count(&wnd), before);
  }

  #[test]
  fn custom_tooltip_survives_second_hover_with_material_shell() {
    reset_test_env!();

    install_animated_tooltip_shell_theme();

    let wnd = TestWindow::new_with_size(
      fn_widget! {
        @Providers {
          providers: [default_tooltip_provider()],
          @MockBox {
            size: Size::new(40., 20.),
            x: 100.,
            y: 80.,
            tooltip: "tip",
          }
        }
      },
      Size::new(240., 200.),
    );
    wnd.draw_frame();

    let before = root_children_count(&wnd);

    for cycle in 0..2 {
      let shown = hover_and_show(&wnd, OFFSET_HOST_POINT);
      assert!(shown > before, "tooltip should mount in hover cycle {cycle}");

      move_cursor_and_draw(&wnd, HOST_POINT);
      wait_for_tooltip_hide_delay();
      wnd.draw_frame();
      assert_eq!(root_children_count(&wnd), before);
    }
  }

  #[test]
  fn custom_tooltip_leave_animation_keeps_bubble_pinned() {
    reset_test_env!();

    install_animated_tooltip_shell_theme();

    let bubble_track = Stateful::new(None::<TrackId>);
    let bubble_track_reader = bubble_track.clone_reader();
    let wnd = TestWindow::new(
      fn_widget! {
        let mut bubble = @MockBox {
          size: Size::new(60., 24.),
        };
        let bubble_id = bubble.track_id();
        bubble.on_mounted(move |_| *$write(bubble_track) = Some($clone(bubble_id)));

        @Providers {
          providers: [default_tooltip_provider()],
          @MockBox {
            size: Size::new(40., 20.),
            x: 100.,
            y: 80.,
            tooltip: Tooltip::from_widget(bubble),
          }
        }
      },
      Size::new(240., 200.),
      WindowFlags::ANIMATIONS,
    );
    wnd.draw_frame();

    let bubble_pos = || {
      let bubble = bubble_track_reader
        .read()
        .clone()
        .and_then(|track| track.get())
        .expect("tooltip bubble should stay mounted while visible or leaving");
      wnd.map_to_global(Point::zero(), bubble)
    };

    hover_and_show(&wnd, OFFSET_HOST_POINT);
    let shown_pos = bubble_pos();

    move_cursor_and_draw(&wnd, HOST_POINT);
    wait_for_tooltip_hide_delay();
    wnd.draw_frame();

    let leave_start = bubble_pos();
    assert_eq!(
      leave_start, shown_pos,
      "tooltip should start leave animation from its shown position"
    );

    wait_for(Duration::from_millis(60));
    wnd.draw_frame();
    let leave_mid = bubble_pos();
    assert_eq!(leave_mid, shown_pos, "tooltip should remain pinned during leave animation");
  }
}