use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};
use crate::base::{Point, Rect, Size};
use crate::layout::{Dimension, Style as LayoutStyle};
use crate::reactive::{create_root, flush_effects, run_due_timers, Scope};
use crate::term::Capabilities;
use crate::testing::CaptureTerm;
use crate::ui::{text, Element, MouseEvent, MouseKind, UiEvent, View};
use crate::widgets::{TextArea, TextAreaState};
use super::super::driver::{Driver, RunConfig};
use super::super::overlays::{OverlayContent, Overlays};
use super::super::popups::{Modal, MODAL_Z};
use super::super::select::{Combobox, MultiSelect, Select, SelectOption};
use super::super::App;
use super::{Completion, CompletionCandidate, TipContent, Tooltip};
const VP: Size = Size::new(110, 34);
fn options3() -> Vec<SelectOption> {
vec![
SelectOption::new("alpha"),
SelectOption::new("beta"),
SelectOption::new("gamma"),
]
}
fn options5() -> Vec<SelectOption> {
vec![
SelectOption::new("alpha"),
SelectOption::new("beta"),
SelectOption::new("gamma"),
SelectOption::new("delta"),
SelectOption::new("epsilon"),
]
}
fn face_layout() -> LayoutStyle {
LayoutStyle::default().w(24).h(1).shrink(0.0)
}
struct DriverRig {
app: App,
term: CaptureTerm,
driver: Driver,
overlays: Overlays,
scope: Scope,
}
fn driver_rig() -> DriverRig {
let mut term = CaptureTerm::new(VP);
let mut app = App::new(VP);
let overlays = app.overlays();
let holder: Rc<RefCell<Option<Scope>>> = Default::default();
let h = holder.clone();
app.mount(move |cx| {
*h.borrow_mut() = Some(cx);
Element::new()
.style(LayoutStyle::column())
.child(text("root content under the modal"))
.build()
})
.expect("mount");
let cfg = RunConfig {
caps: Some(Capabilities::with(|c| {
c.truecolor = true;
c.colors_256 = true;
})),
enter: None,
probe: false,
..RunConfig::default()
};
let driver = Driver::new(&mut app, &mut term, cfg).expect("driver");
let scope = holder.borrow().expect("mount scope");
DriverRig {
app,
term,
driver,
overlays,
scope,
}
}
impl DriverRig {
fn settle(&mut self) {
for _ in 0..64 {
if self
.driver
.turn(&mut self.app, &mut self.term)
.expect("turn")
.idle
{
break;
}
}
}
fn input(&mut self, bytes: &[u8]) {
self.term.push_input(bytes);
self.settle();
}
fn screen(&self) -> String {
self.term.screen().to_text()
}
fn line(&self, y: i32) -> String {
self.screen()
.lines()
.nth(y as usize)
.unwrap_or_default()
.to_string()
}
fn popup_bounds(&self) -> Option<Rect> {
let store = self.overlays.store().borrow();
store
.meta
.iter()
.zip(&store.layers)
.filter(|(m, l)| {
matches!(m.content, OverlayContent::Tree { modal: true, .. }) && l.z() > MODAL_Z
})
.map(|(_, l)| l.bounds())
.next()
}
fn passive_panel_bounds(&self) -> Option<Rect> {
let store = self.overlays.store().borrow();
store
.meta
.iter()
.zip(&store.layers)
.find_map(|(m, l)| match &m.content {
OverlayContent::Tree { modal: false, .. } => Some(l.bounds()),
_ => None,
})
}
}
#[test]
fn select_popup_inside_centered_modal_opens_adjacent_to_trigger() {
let mut rig = driver_rig();
rig.settle();
let _modal = Modal::open(&rig.overlays, rig.scope, VP, Size::new(40, 7), |mcx| {
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.child(text("pick a channel"))
.child(Select::new(options3()).layout(face_layout()).view(mcx))
.build()
});
rig.settle();
let trigger = Rect::new(36, 15, 24, 1);
assert!(
rig.line(trigger.y).contains("choose"),
"trigger renders on its screen row:\n{}",
rig.screen()
);
rig.input(b"\r"); let popup = rig.popup_bounds().expect("popup layer open");
assert_eq!(
popup,
Rect::new(trigger.x, trigger.bottom(), trigger.w, 3),
"popup adjacent to the trigger in SCREEN space (the P1: it \
opened displaced to the top-left by the modal's origin)"
);
assert!(
rig.line(trigger.bottom()).contains("alpha"),
"first option row painted directly under the trigger:\n{}",
rig.screen()
);
}
#[test]
fn combobox_popup_inside_modal_mounts_editor_over_screen_trigger_row() {
let mut rig = driver_rig();
rig.settle();
let _modal = Modal::open(&rig.overlays, rig.scope, VP, Size::new(40, 7), |mcx| {
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.child(text("pick a model"))
.child(Combobox::new(options3()).layout(face_layout()).view(mcx))
.build()
});
rig.settle();
let trigger = Rect::new(36, 15, 24, 1);
rig.input(b"\r");
let popup = rig.popup_bounds().expect("popup layer open");
assert_eq!(
popup,
Rect::new(trigger.x, trigger.y, trigger.w, 5),
"anchor-row-inclusive popup starts AT the trigger's screen row"
);
assert!(
rig.line(trigger.y).contains("search"),
"editor mounted over the trigger row:\n{}",
rig.screen()
);
assert!(
rig.line(trigger.y + 4).contains("3 of 3"),
"status row at the popup's screen bottom:\n{}",
rig.screen()
);
}
#[test]
fn multiselect_popup_inside_modal_opens_adjacent_to_screen_trigger() {
let mut rig = driver_rig();
rig.settle();
let _modal = Modal::open(&rig.overlays, rig.scope, VP, Size::new(40, 7), |mcx| {
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.child(text("pick tags"))
.child(MultiSelect::new(options3()).layout(face_layout()).view(mcx))
.build()
});
rig.settle();
let trigger = Rect::new(36, 15, 24, 1);
rig.input(b"\r");
let popup = rig.popup_bounds().expect("popup layer open");
assert_eq!(popup, Rect::new(trigger.x, trigger.bottom(), trigger.w, 3));
}
#[test]
fn select_popup_flips_above_when_modal_trigger_sits_near_screen_bottom() {
let mut rig = driver_rig();
rig.settle();
let _modal = Modal::open(&rig.overlays, rig.scope, VP, Size::new(40, 30), |mcx| {
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.child(
Element::new()
.style(LayoutStyle::default().grow(1.0))
.build(),
)
.child(Select::new(options5()).layout(face_layout()).view(mcx))
.build()
});
rig.settle();
let trigger = Rect::new(36, 30, 24, 1);
assert!(
rig.line(trigger.y).contains("choose"),
"trigger on its screen row:\n{}",
rig.screen()
);
rig.input(b"\r");
let popup = rig.popup_bounds().expect("popup layer open");
assert_eq!(
popup,
Rect::new(trigger.x, trigger.y - 5, trigger.w, 5),
"3 rows below vs 30 above at the SCREEN anchor: the popup \
flips ABOVE and ends adjacent to the trigger row"
);
assert_eq!(
popup.bottom(),
trigger.y,
"flipped popup's bottom edge touches the trigger"
);
}
#[test]
fn completion_dropdown_inside_modal_anchors_at_screen_caret() {
let mut rig = driver_rig();
rig.settle();
let state_holder: Rc<RefCell<Option<TextAreaState>>> = Default::default();
let sh = state_holder.clone();
let ov = rig.overlays.clone();
let _modal = Modal::open(&rig.overlays, rig.scope, VP, Size::new(40, 7), move |mcx| {
let t = crate::widgets::theme_tokens(mcx);
let state = TextAreaState::new(mcx);
*sh.borrow_mut() = Some(state.clone());
let composer = TextArea::new()
.state(&state)
.rows(1, 3)
.element(mcx, &t)
.build();
Completion::new()
.trigger('/', |query| {
["help", "theme", "clear"]
.iter()
.filter(|c| c.starts_with(query))
.map(|c| CompletionCandidate::new(format!("/{c}"), format!("/{c} ")))
.collect()
})
.max_visible(3)
.attach(mcx, &ov, &state, composer)
});
rig.settle();
rig.input(b"/");
let state = state_holder.borrow().clone().expect("state");
let cell = state
.caret_cell()
.get_untracked()
.expect("focused composer published its caret");
let screen_cell = Point::new(cell.x + 35, cell.y + 13);
let panel = rig.passive_panel_bounds().expect("dropdown open");
assert_eq!(
(panel.x, panel.y),
(screen_cell.x, screen_cell.y + 1),
"dropdown directly under the caret's SCREEN cell (caret local \
{cell:?}, modal at (35, 13)); got {panel:?}"
);
assert!(
rig.line(panel.y).contains("/help"),
"candidates painted at the screen position:\n{}",
rig.screen()
);
}
#[test]
fn root_layer_select_popup_geometry_unchanged() {
let mut term = CaptureTerm::new(VP);
let mut app = App::new(VP);
let overlays = app.overlays();
app.mount(move |cx| {
Element::new()
.style(LayoutStyle::column())
.child(text("header row"))
.child(Select::new(options3()).layout(face_layout()).view(cx))
.build()
})
.expect("mount");
let cfg = RunConfig {
caps: Some(Capabilities::with(|c| {
c.truecolor = true;
c.colors_256 = true;
})),
enter: None,
probe: false,
..RunConfig::default()
};
let mut driver = Driver::new(&mut app, &mut term, cfg).expect("driver");
let mut settle = |app: &mut App, term: &mut CaptureTerm| {
for _ in 0..64 {
if driver.turn(app, term).expect("turn").idle {
break;
}
}
};
settle(&mut app, &mut term);
term.push_input(b"\t"); settle(&mut app, &mut term);
term.push_input(b"\r");
settle(&mut app, &mut term);
let popup = {
let store = overlays.store().borrow();
store
.meta
.iter()
.zip(&store.layers)
.find_map(|(m, l)| match &m.content {
OverlayContent::Tree { modal: true, .. } => Some(l.bounds()),
_ => None,
})
.expect("popup open")
};
assert_eq!(
popup,
Rect::new(0, 2, 24, 3),
"origin (0,0): local == screen, placement byte-identical"
);
let screen = term.screen().to_text();
let line2 = screen.lines().nth(2).unwrap_or_default();
assert!(line2.contains("alpha"), "first option on row 2:\n{screen}");
}
#[test]
fn tooltip_inside_positioned_overlay_places_tip_at_screen_position() {
let vp = Size::new(60, 20);
super::super::viewport::publish_viewport(vp);
let overlays = Overlays::new();
overlays.ensure_root(vp);
let panel_bounds = Rect::new(10, 4, 20, 6);
let ov = overlays.clone();
let (root, _layer) = create_root(|cx| {
let target = Element::new()
.style(LayoutStyle::line(1).w(10))
.child(text("hover me"))
.build();
let wrapped = Tooltip::attach(cx, &ov, "the tip", Duration::ZERO, target);
let view = Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.child(wrapped)
.child(text("below"))
.build();
ov.layer_tree(5, panel_bounds, false, cx, view)
});
overlays.dispatch(&UiEvent::Mouse(MouseEvent {
pos: Point::new(12, 4),
kind: MouseKind::Move,
mods: crate::ui::Mods::NONE,
}));
flush_effects();
run_due_timers(Instant::now());
let tip = {
let store = overlays.store().borrow();
store
.meta
.iter()
.zip(&store.layers)
.find_map(|(m, l)| match &m.content {
OverlayContent::Draw { .. } => Some(l.bounds()),
_ => None,
})
.expect("tip layer open")
};
assert_eq!(
(tip.x, tip.y),
(10, 5),
"tip under the hovered element's SCREEN row (panel at (10, 4)), \
not its layer-local row; got {tip:?}"
);
root.dispose();
}
struct TipRig {
app: App,
term: CaptureTerm,
driver: Driver,
overlays: Overlays,
}
fn tip_rig(make: impl Fn(Scope, &Overlays, View) -> View + 'static) -> TipRig {
let mut term = CaptureTerm::new(VP);
let mut app = App::new(VP);
let overlays = app.overlays();
let ov = overlays.clone();
app.mount(move |cx| {
let target = Element::new()
.style(LayoutStyle::line(1).w(8))
.child(text("cite"))
.build();
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.child(make(cx, &ov, target))
.build()
})
.expect("mount");
let cfg = RunConfig {
caps: Some(Capabilities::with(|c| {
c.truecolor = true;
c.colors_256 = true;
})),
enter: None,
probe: false,
hover_ink: false,
..RunConfig::default()
};
let driver = Driver::new(&mut app, &mut term, cfg).expect("driver");
TipRig {
app,
term,
driver,
overlays,
}
}
impl TipRig {
fn settle(&mut self) {
for _ in 0..64 {
if self
.driver
.turn(&mut self.app, &mut self.term)
.expect("turn")
.idle
{
break;
}
}
}
fn mouse_to(&mut self, x: i32) {
self.term
.push_input(format!("\x1b[<35;{};1M", x + 1).as_bytes());
self.settle();
flush_effects();
}
fn keys(&mut self, bytes: &[u8]) {
self.term.push_input(bytes);
self.settle();
flush_effects();
}
fn fire_timers(&mut self, after: Duration) {
run_due_timers(Instant::now() + after);
flush_effects();
self.settle();
}
fn screen(&self) -> String {
self.term.screen().to_text()
}
fn layer_kinds(&self) -> Vec<&'static str> {
let store = self.overlays.store().borrow();
store
.meta
.iter()
.map(|m| match &m.content {
OverlayContent::Tree { .. } => "tree",
OverlayContent::Draw { .. } => "draw",
_ => "other",
})
.collect()
}
}
#[test]
fn a_card_tip_mounts_a_tree_and_its_content_reaches_the_screen() {
let mut rig = tip_rig(|cx, ov, target| {
Tooltip::attach_content(
cx,
ov,
TipContent::card(Size::new(24, 3), |_cx| {
Element::new()
.style(LayoutStyle::column())
.child(text("cited body"))
.build()
}),
Duration::ZERO,
target,
)
});
rig.settle();
assert!(
!rig.screen().contains("cited body"),
"a dormant tip must paint nothing"
);
rig.mouse_to(0);
rig.fire_timers(Duration::ZERO);
let kinds = rig.layer_kinds();
assert!(
kinds.contains(&"tree"),
"a card must open a TREE layer, not a draw layer; got {kinds:?}"
);
assert!(
!kinds.contains(&"draw"),
"a card must not fall back to the label's draw path: {kinds:?}"
);
assert!(
rig.screen().contains("cited body"),
"the card's widget content never reached the screen:\n{}",
rig.screen()
);
}
#[test]
fn a_label_tip_still_takes_the_zero_tree_draw_path() {
let mut rig =
tip_rig(|cx, ov, target| Tooltip::attach(cx, ov, "plain tip", Duration::ZERO, target));
rig.settle();
rig.mouse_to(0);
rig.fire_timers(Duration::ZERO);
let kinds = rig.layer_kinds();
assert_eq!(
kinds.iter().filter(|k| **k == "draw").count(),
1,
"the label path must stay exactly one draw layer; got {kinds:?}"
);
assert!(
rig.screen().contains("plain tip"),
"the label never painted:\n{}",
rig.screen()
);
}
#[test]
fn a_card_tip_does_not_open_when_the_pointer_left_before_the_delay() {
let mut rig = tip_rig(|cx, ov, target| {
Tooltip::attach_content(
cx,
ov,
TipContent::card(Size::new(20, 3), |_cx| text("must not appear")),
Duration::from_millis(50),
target,
)
});
rig.settle();
rig.mouse_to(0); rig.mouse_to(60); rig.fire_timers(Duration::from_millis(100));
assert!(
!rig.screen().contains("must not appear"),
"a stale timer opened a card after the pointer left:\n{}",
rig.screen()
);
}
fn tall_card(rows: i32) -> TipContent {
TipContent::card(Size::new(24, rows), move |_cx| {
let mut col = Element::new().style(LayoutStyle::column());
for i in 0..rows {
col = col.child(text(format!("row {i}")));
}
col.build()
})
}
#[test]
fn a_card_too_tall_for_the_viewport_says_how_much_it_hid() {
let mut rig = tip_rig(|cx, ov, target| {
Tooltip::attach_content(cx, ov, tall_card(40), Duration::ZERO, target)
});
rig.settle();
rig.mouse_to(0);
rig.fire_timers(Duration::ZERO);
let screen = rig.screen();
assert!(
screen.contains("… 8 more"),
"a card cut from 40 rows to 33 reported nothing:\n{screen}"
);
assert!(
!screen.contains("row 39"),
"the last row cannot be on screen if the card was truncated:\n{screen}"
);
}
#[test]
fn a_card_that_fits_carries_no_truncation_marker() {
let mut rig = tip_rig(|cx, ov, target| {
Tooltip::attach_content(cx, ov, tall_card(3), Duration::ZERO, target)
});
rig.settle();
rig.mouse_to(0);
rig.fire_timers(Duration::ZERO);
let screen = rig.screen();
assert!(
screen.contains("row 2"),
"the card did not open at all, so the assertion below proves nothing:\n{screen}"
);
assert!(
!screen.contains("more"),
"a card that fits whole must not claim it hid rows:\n{screen}"
);
}
type TipHandles = (
crate::reactive::Signal<i32>,
crate::reactive::Signal<&'static str>,
);
fn scrolling_tip_rig(
make: impl Fn(Scope, &Overlays, View) -> View + 'static,
) -> (TipRig, TipHandles) {
let mut term = CaptureTerm::new(VP);
let mut app = App::new(VP);
let overlays = app.overlays();
let ov = overlays.clone();
let out: Rc<RefCell<Option<TipHandles>>> = Rc::new(RefCell::new(None));
let sink = out.clone();
app.mount(move |cx| {
let shift = cx.signal(0i32);
let label = cx.signal("cite");
*sink.borrow_mut() = Some((shift, label));
let target = Element::new()
.style(LayoutStyle::line(1).w(8))
.child(crate::ui::dyn_view(LayoutStyle::default(), move || {
text(label.get())
}))
.build();
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.child(
Element::new()
.style_signal(move || LayoutStyle::default().w(1).h(shift.get()))
.build(),
)
.child(make(cx, &ov, target))
.build()
})
.expect("mount");
let cfg = RunConfig {
caps: Some(Capabilities::with(|c| {
c.truecolor = true;
c.colors_256 = true;
})),
enter: None,
probe: false,
hover_ink: false,
..RunConfig::default()
};
let driver = Driver::new(&mut app, &mut term, cfg).expect("driver");
let handles = out.borrow().expect("mount ran and published its signals");
(
TipRig {
app,
term,
driver,
overlays,
},
handles,
)
}
#[test]
fn a_card_tip_closes_when_its_anchor_scrolls_out_from_under_it() {
let (mut rig, (shift, _label)) = scrolling_tip_rig(|cx, ov, target| {
Tooltip::attach_content(cx, ov, tall_card(3), Duration::ZERO, target)
});
rig.settle();
rig.mouse_to(0);
rig.fire_timers(Duration::ZERO);
assert!(
rig.screen().contains("row 2"),
"the card never opened, so the assertion below would pass vacuously:\n{}",
rig.screen()
);
shift.set(6);
flush_effects();
rig.settle();
rig.fire_timers(Duration::ZERO);
assert!(
!rig.screen().contains("row 2"),
"the anchor moved 6 rows and the card stayed at its open-time \
coordinates, describing a line that is no longer there:\n{}",
rig.screen()
);
}
#[test]
fn a_card_tip_survives_its_anchor_repainting_in_place() {
let (mut rig, (_shift, label)) = scrolling_tip_rig(|cx, ov, target| {
Tooltip::attach_content(cx, ov, tall_card(3), Duration::ZERO, target)
});
rig.settle();
rig.mouse_to(0);
rig.fire_timers(Duration::ZERO);
assert!(rig.screen().contains("row 2"), "the card never opened");
label.set("cit3");
flush_effects();
rig.settle();
rig.fire_timers(Duration::ZERO);
assert!(
rig.screen().contains("cit3"),
"the anchor never actually repainted, so the assertion below \
would prove nothing:\n{}",
rig.screen()
);
assert!(
rig.screen().contains("row 2"),
"the anchor repainted where it already was and the card closed \
anyway — the draw-time rect disagrees with the event-time \
capture:\n{}",
rig.screen()
);
}
#[test]
fn a_label_too_wide_for_the_viewport_ends_in_an_ellipsis() {
let long = format!("{} END", "wide ".repeat(40));
let mut rig = tip_rig(move |cx, ov, target| {
Tooltip::attach(cx, ov, long.clone(), Duration::ZERO, target)
});
rig.settle();
rig.mouse_to(0);
rig.fire_timers(Duration::ZERO);
let screen = rig.screen();
assert!(
screen.contains('…'),
"a label 205 cells wide in a 110-cell viewport was cut with no marker:\n{screen}"
);
assert!(
!screen.contains("END"),
"the tail cannot be on screen if the label was truncated:\n{screen}"
);
}
fn focusable_tip_rig(make: impl Fn(Scope, &Overlays, View) -> View + 'static) -> TipRig {
let mut term = CaptureTerm::new(VP);
let mut app = App::new(VP);
let overlays = app.overlays();
let ov = overlays.clone();
app.mount(move |cx| {
let target = Element::new()
.style(LayoutStyle::line(1).w(8))
.focusable()
.child(text("cite"))
.build();
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.child(make(cx, &ov, target))
.child(
Element::new()
.style(LayoutStyle::line(1).w(8))
.focusable()
.child(text("elsewhere"))
.build(),
)
.build()
})
.expect("mount");
let cfg = RunConfig {
caps: Some(Capabilities::with(|c| {
c.truecolor = true;
c.colors_256 = true;
})),
enter: None,
probe: false,
hover_ink: false,
..RunConfig::default()
};
let driver = Driver::new(&mut app, &mut term, cfg).expect("driver");
TipRig {
app,
term,
driver,
overlays,
}
}
#[test]
fn mounting_a_tooltip_arms_motion_reporting_without_the_app_asking() {
let mut term = CaptureTerm::new(VP);
let mut app = App::new(VP);
let overlays = app.overlays();
let ov = overlays.clone();
app.mount(move |cx| {
Element::new()
.style(LayoutStyle::column())
.child(Tooltip::attach(
cx,
&ov,
"a tip",
Duration::from_millis(1),
Element::new()
.style(LayoutStyle::line(1).w(8))
.child(text("cite"))
.build(),
))
.build()
})
.expect("mount");
assert!(
overlays.pointer_motion_required(),
"mounting a Tooltip must declare that this app needs motion \
reports — nothing else will ask for them"
);
let _driver = Driver::new(
&mut app,
&mut term,
RunConfig {
probe: false,
hover_ink: false,
..RunConfig::default()
},
)
.expect("driver");
assert_eq!(
term.enter_options().expect("entered").mouse,
crate::term::MouseMode::AnyMotion,
"a tooltip app entered in ButtonDrag: the terminal only reports \
motion while a button is down, so the tip opens on CLICK"
);
let wrote = String::from_utf8_lossy(term.bytes()).into_owned();
assert!(
wrote.contains("[?1003h"),
"mode 1003 never reached the terminal, so no amount of hovering \
will produce a MouseEnter"
);
}
#[test]
fn an_app_without_a_tooltip_still_pays_nothing_for_motion() {
let mut term = CaptureTerm::new(VP);
let mut app = App::new(VP);
let overlays = app.overlays();
app.mount(|_cx| {
Element::new()
.style(LayoutStyle::column())
.child(text("no tips here"))
.build()
})
.expect("mount");
assert!(!overlays.pointer_motion_required());
let _driver = Driver::new(
&mut app,
&mut term,
RunConfig {
probe: false,
..RunConfig::default()
},
)
.expect("driver");
assert_eq!(
term.enter_options().expect("entered").mouse,
crate::term::MouseMode::ButtonDrag,
"an app with no motion-dependent widget must not wake its event \
loop on every pointer cell"
);
}
#[test]
fn a_card_tip_opens_when_its_anchor_takes_focus() {
let mut rig = focusable_tip_rig(|cx, ov, target| {
Tooltip::attach_content(cx, ov, tall_card(3), Duration::ZERO, target)
});
rig.settle();
assert!(
!rig.screen().contains("row 2"),
"a dormant tip must paint nothing"
);
rig.keys(b"\t");
rig.fire_timers(Duration::ZERO);
assert!(
rig.screen().contains("row 2"),
"Tab focused the anchor and no tip appeared — the card is \
reachable by mouse only:\n{}",
rig.screen()
);
}
#[test]
fn a_focus_opened_tip_closes_when_focus_moves_on() {
let mut rig = focusable_tip_rig(|cx, ov, target| {
Tooltip::attach_content(cx, ov, tall_card(3), Duration::ZERO, target)
});
rig.settle();
rig.keys(b"\t");
rig.fire_timers(Duration::ZERO);
assert!(
rig.screen().contains("row 2"),
"the tip never opened, so the assertion below would pass \
vacuously:\n{}",
rig.screen()
);
rig.keys(b"\t");
rig.fire_timers(Duration::ZERO);
assert!(
!rig.screen().contains("row 2"),
"focus moved to the next widget and the card stayed up:\n{}",
rig.screen()
);
}
#[test]
fn escape_dismisses_an_open_tip_and_passes_through_when_there_is_none() {
let seen: Rc<RefCell<u32>> = Rc::new(RefCell::new(0));
let counter = seen.clone();
let mut rig = focusable_tip_rig(move |cx, ov, target| {
let counter = counter.clone();
Element::new()
.on(crate::ui::Phase::Bubble, move |_ctx, ev| {
if matches!(ev, UiEvent::Key(k) if k.key == crate::ui::Key::Escape) {
*counter.borrow_mut() += 1;
}
})
.child(Tooltip::attach_content(
cx,
ov,
tall_card(3),
Duration::ZERO,
target,
))
.build()
});
rig.settle();
rig.keys(b"\t");
rig.fire_timers(Duration::ZERO);
assert!(
rig.screen().contains("row 2"),
"the tip never opened:\n{}",
rig.screen()
);
rig.keys(b"\x1b[27u");
rig.fire_timers(Duration::ZERO);
assert!(
!rig.screen().contains("row 2"),
"Escape left the card up:\n{}",
rig.screen()
);
assert_eq!(
*seen.borrow(),
0,
"the Escape that dismissed the tip must be consumed, not also \
acted on by the app behind it"
);
rig.keys(b"\x1b[27u");
rig.fire_timers(Duration::ZERO);
assert_eq!(
*seen.borrow(),
1,
"an Escape with no tip open was swallowed — the anchor has \
become a hole where Escape stops working"
);
}
#[test]
fn a_focusable_descendant_of_the_anchor_does_not_trigger_the_tip() {
let mut term = CaptureTerm::new(VP);
let mut app = App::new(VP);
let overlays = app.overlays();
let ov = overlays.clone();
app.mount(move |cx| {
let focused = cx.signal(false);
let target = Element::new()
.style(LayoutStyle::line(1).w(20))
.child(
Element::new()
.style(LayoutStyle::line(1).w(20))
.focusable()
.focus_signal(focused)
.child(crate::ui::dyn_view(LayoutStyle::default(), move || {
text(if focused.get() {
"INNER-FOCUSED"
} else {
"cite"
})
}))
.build(),
)
.build();
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.child(Tooltip::attach_content(
cx,
&ov,
tall_card(3),
Duration::ZERO,
target,
))
.build()
})
.expect("mount");
let cfg = RunConfig {
caps: Some(Capabilities::with(|c| {
c.truecolor = true;
c.colors_256 = true;
})),
enter: None,
probe: false,
hover_ink: false,
..RunConfig::default()
};
let driver = Driver::new(&mut app, &mut term, cfg).expect("driver");
let mut rig = TipRig {
app,
term,
driver,
overlays,
};
rig.settle();
rig.keys(b"\t");
rig.fire_timers(Duration::ZERO);
assert!(
rig.screen().contains("INNER-FOCUSED"),
"the descendant never took focus, so the assertion below would \
pass for the wrong reason:\n{}",
rig.screen()
);
assert!(
!rig.screen().contains("row 2"),
"a tip opened for a focus its listener cannot hear — either \
focus started bubbling or the trigger moved; whichever it is, \
the documented limit on `attach_content` is now wrong:\n{}",
rig.screen()
);
}