use pangocairo::cairo::{Context, Format, ImageSurface};
use crate::backend::Backend;
use crate::dispatch::route_pointer;
use crate::runner::{AppLogic, Reaction};
use crate::shell::{ShellApp, ShellConfig};
use crate::testing::driver_core::DriverCore;
use crate::testing::{
Anchor, ConformanceDriver, DriverInput, FrameInventory, LogicalViewport, PixelClickConformance,
TextRun,
};
use crate::{ButtonMask, Key, Modifiers, MouseButton, NamedKey, Point, UiEvent, WidgetId};
use super::backend::GtkBackend;
use super::run::{dispatch_event, render_frame};
pub fn driver_with_shell<A: ShellApp + 'static>(
app: A,
config: ShellConfig,
width: i32,
height: i32,
) -> GtkDriver<impl AppLogic> {
let adapter = crate::shell_adapter::build_shell_adapter(app, config);
GtkDriver::new(adapter, width, height)
}
pub struct GtkDriver<A: AppLogic> {
core: DriverCore<GtkBackend, A>,
surface: ImageSurface,
width: i32,
height: i32,
}
impl<A: AppLogic> GtkDriver<A> {
pub fn new(app: A, width: i32, height: i32) -> Self {
let surface =
ImageSurface::create(Format::ARgb32, width, height).expect("create ImageSurface");
let mut backend = GtkBackend::new();
backend.set_painted_text_recording(true);
{
let cr = Context::new(&surface).expect("Context::new on headless ImageSurface");
let pctx = pangocairo::functions::create_context(&cr);
pctx.set_font_description(&pangocairo::pango::FontDescription::from_string("Sans 11"));
backend.set_pango_context(pctx);
}
backend.begin_frame(crate::Viewport::new(width as f32, height as f32, 1.0));
let mut app = app;
app.setup(&mut backend);
let mut driver = Self {
core: DriverCore::new(app, backend),
surface,
width,
height,
};
driver.render();
driver
}
pub fn render(&mut self) {
let cr = Context::new(&self.surface).expect("Context::new on headless ImageSurface");
let (backend, app) = self.core.parts_mut();
render_frame(backend, app, &cr, self.width, self.height);
}
pub fn dispatch(&mut self, event: UiEvent) -> Reaction {
if self.core.exited() {
return Reaction::Exit;
}
let outcome = {
let (backend, app) = self.core.parts_mut();
dispatch_event(event, backend, app)
};
let surface = &self.surface;
let width = self.width;
let height = self.height;
self.core.apply_outcome(outcome, |backend, app| {
let cr = Context::new(surface).expect("Context::new on headless ImageSurface");
render_frame(backend, app, &cr, width, height);
})
}
pub fn set_double_click_folding(&mut self, enabled: bool) {
self.core.backend_mut().set_double_click_folding(enabled);
}
pub fn press(&mut self, key: Key) -> Reaction {
DriverInput::press(self, key)
}
pub fn type_char(&mut self, c: char) -> Reaction {
DriverInput::type_char(self, c)
}
pub fn press_named(&mut self, key: NamedKey) -> Reaction {
DriverInput::press_named(self, key)
}
pub fn ctrl_char(&mut self, c: char) -> Reaction {
DriverInput::ctrl_char(self, c)
}
pub fn click(&mut self, x: f32, y: f32) -> Reaction {
DriverInput::click(self, x, y)
}
pub fn mouse_down(&mut self, x: f32, y: f32) -> Reaction {
let position = Point::new(x, y);
let events = {
let backend = self.core.backend_mut();
let stack_rc = backend.modal_stack_handle();
let drag_rc = backend.drag_state_handle();
let stack = stack_rc.borrow();
let mut drag = drag_rc.borrow_mut();
route_pointer(
&stack,
&mut drag,
backend.text_selection_state_mut(),
UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position,
modifiers: Modifiers::default(),
},
)
};
self.dispatch_all(events)
}
pub fn mouse_move(&mut self, x: f32, y: f32) -> Reaction {
let position = Point::new(x, y);
let events = {
let backend = self.core.backend_mut();
let stack_rc = backend.modal_stack_handle();
let drag_rc = backend.drag_state_handle();
let stack = stack_rc.borrow();
let mut drag = drag_rc.borrow_mut();
route_pointer(
&stack,
&mut drag,
backend.text_selection_state_mut(),
UiEvent::MouseMoved {
position,
buttons: ButtonMask {
left: true,
..ButtonMask::default()
},
},
)
};
self.dispatch_all(events)
}
pub fn mouse_up(&mut self, x: f32, y: f32) -> Reaction {
let position = Point::new(x, y);
let events = {
let backend = self.core.backend_mut();
let stack_rc = backend.modal_stack_handle();
let drag_rc = backend.drag_state_handle();
let stack = stack_rc.borrow();
let mut drag = drag_rc.borrow_mut();
route_pointer(
&stack,
&mut drag,
backend.text_selection_state_mut(),
UiEvent::MouseUp {
widget: None,
button: MouseButton::Left,
position,
},
)
};
self.dispatch_all(events)
}
pub fn drag(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) -> Reaction {
DriverInput::drag(self, x0, y0, x1, y1)
}
fn dispatch_all(&mut self, events: Vec<UiEvent>) -> Reaction {
let mut result = Reaction::Continue;
for ev in events {
let step = self.dispatch(ev);
if step == Reaction::Exit {
return Reaction::Exit;
}
result = result.merge(step);
}
result
}
pub fn exited(&self) -> bool {
self.core.exited()
}
pub fn app(&self) -> &A {
self.core.app()
}
pub fn app_mut(&mut self) -> &mut A {
self.core.app_mut()
}
pub fn backend(&self) -> &GtkBackend {
self.core.backend()
}
pub fn pixel(&mut self, x: i32, y: i32) -> (u8, u8, u8) {
self.surface.flush();
let stride = self.surface.stride() as usize;
let data = self.surface.data().expect("surface data");
let off = y as usize * stride + x as usize * 4;
(data[off + 2], data[off + 1], data[off])
}
pub fn screen(&mut self) -> Vec<u8> {
self.surface.flush();
self.surface.data().expect("surface data").to_vec()
}
pub fn stride(&self) -> i32 {
self.surface.stride()
}
pub fn painted_texts(&self) -> Vec<&str> {
self.core
.backend()
.painted_text
.iter()
.map(|p| p.text.as_str())
.collect()
}
pub fn screen_contains(&self, needle: &str) -> bool {
self.core
.backend()
.painted_text
.iter()
.any(|p| p.text.contains(needle))
}
pub fn find_bounds(&self, needle: &str) -> Option<crate::Rect> {
self.core
.backend()
.painted_text
.iter()
.find(|p| p.text.contains(needle))
.map(|p| p.bounds)
}
pub fn find(&self, needle: &str) -> Option<(f32, f32)> {
self.find_bounds(needle)
.map(|b| (b.x + b.width / 2.0, b.y + b.height / 2.0))
}
pub fn tab_center(&self, bar: &WidgetId, tab_idx: usize) -> Option<(f32, f32)> {
let (rect, layout) = self.core.backend().cached_tab_bar_layout(bar)?;
let (cx, cy) = layout.tab_center(tab_idx)?;
Some((rect.x + cx, rect.y + cy))
}
pub fn tab_close_center(&self, bar: &WidgetId, tab_idx: usize) -> Option<(f32, f32)> {
let (rect, layout) = self.core.backend().cached_tab_bar_layout(bar)?;
let (cx, cy) = layout.tab_close_center(tab_idx)?;
Some((rect.x + cx, rect.y + cy))
}
}
impl<A: AppLogic> DriverInput for GtkDriver<A> {
fn dispatch(&mut self, event: UiEvent) -> Reaction {
self.dispatch(event)
}
fn mouse_down(&mut self, x: f32, y: f32) -> Reaction {
self.mouse_down(x, y)
}
fn mouse_move(&mut self, x: f32, y: f32) -> Reaction {
self.mouse_move(x, y)
}
fn mouse_up(&mut self, x: f32, y: f32) -> Reaction {
self.mouse_up(x, y)
}
}
impl<A: AppLogic> PixelClickConformance for GtkDriver<A> {
const NAME: &'static str = "GtkDriver";
fn find_bounds(&self, needle: &str) -> Option<crate::Rect> {
self.find_bounds(needle)
}
fn find(&self, needle: &str) -> Option<(f32, f32)> {
self.find(needle)
}
fn conformance_line_height(&self) -> f32 {
self.core.backend().line_height()
}
}
impl<A: AppLogic> ConformanceDriver for GtkDriver<A> {
type App = A;
fn new_fixture(app: Self::App, viewport: LogicalViewport) -> Self {
const NOMINAL_CHAR_WIDTH: i32 = 8;
const NOMINAL_LINE_HEIGHT: i32 = 16;
GtkDriver::new(
app,
viewport.cols as i32 * NOMINAL_CHAR_WIDTH,
viewport.rows as i32 * NOMINAL_LINE_HEIGHT,
)
}
fn backend_caps(&self) -> crate::BackendCaps {
crate::Backend::backend_caps(self.core.backend())
}
fn press_named(&mut self, key: NamedKey) {
GtkDriver::press_named(self, key);
}
fn type_char(&mut self, c: char) {
GtkDriver::type_char(self, c);
}
fn ctrl_char(&mut self, c: char) {
GtkDriver::ctrl_char(self, c);
}
fn click_text_at(&mut self, needle: &str, at: Anchor) {
PixelClickConformance::click_text_at(self, needle, at)
}
fn drag_text(&mut self, from: &str, to: &str) {
PixelClickConformance::drag_text(self, from, to)
}
fn scroll_at(&mut self, needle: &str, lines: i32) {
PixelClickConformance::scroll_at(self, needle, lines)
}
fn inventory(&self) -> FrameInventory {
FrameInventory {
text_runs: self
.core
.backend()
.painted_text
.iter()
.map(|p| TextRun {
text: p.text.clone(),
bounds: p.bounds,
})
.collect(),
zones: self.core.backend().zones.clone(),
}
}
fn screen_has(&self, needle: &str) -> bool {
self.screen_contains(needle)
}
fn exited(&self) -> bool {
GtkDriver::exited(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::status_bar::{StatusBar, StatusBarSegment};
use crate::types::{Color, WidgetId};
use crate::Rect;
const W: i32 = 120;
const H: i32 = 24;
const KNOWN_BG: Color = Color::rgb(0, 255, 0);
struct StatusBarApp;
impl AppLogic for StatusBarApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_status_bar_interactive(
Rect::new(0.0, 0.0, W as f32, H as f32),
&StatusBar {
id: WidgetId::new("status"),
left_segments: vec![StatusBarSegment {
text: "known-pixel".to_string(),
fg: Color::rgb(255, 255, 255),
bg: KNOWN_BG,
bold: false,
action_id: None,
}],
right_segments: vec![],
},
&crate::InteractionState::new(),
);
}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
#[test]
fn renders_offscreen_and_reads_back_known_pixel() {
let mut driver = GtkDriver::new(StatusBarApp, W, H);
let sample_x = W - 4;
let label = driver
.find_bounds("known-pixel")
.expect("the segment's label should have been painted");
assert!(
(sample_x as f32) > label.x + label.width,
"sample x={sample_x} must sit right of the label's own bounds \
(x={}, width={}) so it reads the bar's fill, not a glyph stroke",
label.x,
label.width
);
let (r, g, b) = driver.pixel(sample_x, H / 2);
assert_eq!(
(r, g, b),
(KNOWN_BG.r, KNOWN_BG.g, KNOWN_BG.b),
"expected the status bar segment's known background colour, got ({r}, {g}, {b})"
);
}
#[test]
fn find_locates_status_bar_segment_by_text() {
let mut driver = GtkDriver::new(StatusBarApp, W, H);
let bounds = driver
.find_bounds("known-pixel")
.expect("find_bounds should locate the painted segment");
assert_eq!(
(bounds.x, bounds.y),
(crate::primitives::status_bar::PIXEL_EDGE_INSET, 0.0),
"the only segment should start `PIXEL_EDGE_INSET` in from the bar's \
own left edge (issue #1155), not flush at its origin"
);
let (x, y) = driver
.find("known-pixel")
.expect("find should locate the painted segment label");
assert!(
x >= bounds.x && x <= bounds.x + bounds.width,
"find()'s x should fall within the segment's own bounds"
);
assert!(
y >= bounds.y && y <= bounds.y + bounds.height,
"find()'s y should fall within the segment's own bounds"
);
let (r, g, b) = dominant_pixel(&mut driver, bounds);
assert_eq!(
(r, g, b),
(KNOWN_BG.r, KNOWN_BG.g, KNOWN_BG.b),
"dominant colour within find_bounds() should be the segment's bg, got ({r}, {g}, {b})"
);
assert!(driver.screen_contains("known-pixel"));
assert!(!driver.screen_contains("no such label"));
}
fn dominant_pixel<A: AppLogic>(driver: &mut GtkDriver<A>, bounds: Rect) -> (u8, u8, u8) {
use std::collections::HashMap;
let mut counts: HashMap<(u8, u8, u8), u32> = HashMap::new();
for py in bounds.y as i32..(bounds.y + bounds.height) as i32 {
for px in bounds.x as i32..(bounds.x + bounds.width) as i32 {
*counts.entry(driver.pixel(px, py)).or_insert(0) += 1;
}
}
counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(color, _)| color)
.expect("bounds should contain at least one pixel")
}
struct ToggleStatusBarApp {
on: bool,
}
impl ToggleStatusBarApp {
const OFF_BG: Color = Color::rgb(40, 40, 40);
const ON_BG: Color = Color::rgb(0, 200, 0);
fn bar(&self) -> StatusBar {
StatusBar {
id: WidgetId::new("status"),
left_segments: vec![StatusBarSegment {
text: "Toggle".to_string(),
fg: Color::rgb(255, 255, 255),
bg: if self.on { Self::ON_BG } else { Self::OFF_BG },
bold: false,
action_id: Some(WidgetId::new("toggle")),
}],
right_segments: vec![],
}
}
}
impl AppLogic for ToggleStatusBarApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_status_bar_interactive(
Rect::new(0.0, 0.0, W as f32, H as f32),
&self.bar(),
&crate::InteractionState::new(),
);
}
fn handle(&mut self, event: UiEvent, backend: &mut dyn Backend) -> Reaction {
if let UiEvent::MouseDown { position, .. } = event {
let layout =
backend.status_bar_layout(Rect::new(0.0, 0.0, W as f32, H as f32), &self.bar());
if layout.hit_test(position.x, position.y)
== crate::primitives::status_bar::StatusBarHit::Segment(WidgetId::new("toggle"))
{
self.on = !self.on;
return Reaction::Redraw;
}
}
Reaction::Continue
}
}
#[test]
fn find_click_asserts_pixel_and_geometry_change() {
let mut driver = GtkDriver::new(ToggleStatusBarApp { on: false }, W, H);
let (x, y) = driver
.find("Toggle")
.expect("find should locate the toggle segment before any click");
let bounds_before = driver.find_bounds("Toggle").expect("bounds before click");
let pixel_before = dominant_pixel(&mut driver, bounds_before);
assert_eq!(
pixel_before,
(
ToggleStatusBarApp::OFF_BG.r,
ToggleStatusBarApp::OFF_BG.g,
ToggleStatusBarApp::OFF_BG.b
),
"segment should start OFF-coloured"
);
let reaction = driver.click(x, y);
assert_eq!(reaction, Reaction::Redraw, "click should trigger a redraw");
assert!(
driver.app().on,
"click should have flipped the toggle state"
);
let pixel_after = dominant_pixel(&mut driver, bounds_before);
assert_eq!(
pixel_after,
(
ToggleStatusBarApp::ON_BG.r,
ToggleStatusBarApp::ON_BG.g,
ToggleStatusBarApp::ON_BG.b
),
"segment should be ON-coloured after the click toggled state"
);
assert_ne!(
pixel_before, pixel_after,
"click should change the painted pixel"
);
let bounds_after = driver
.find_bounds("Toggle")
.expect("bounds after click should still resolve");
assert_eq!(
bounds_before, bounds_after,
"segment geometry should be unchanged by a colour-only redraw"
);
}
#[test]
fn setup_sees_real_viewport_not_default() {
use std::cell::Cell;
use std::rc::Rc;
struct ViewportRecorder {
seen: Rc<Cell<Option<crate::Viewport>>>,
}
impl AppLogic for ViewportRecorder {
type AreaId = ();
fn setup(&mut self, backend: &mut dyn Backend) {
self.seen.set(Some(backend.viewport()));
}
fn render(&self, _backend: &mut dyn Backend, _area: ()) {}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
let seen = Rc::new(Cell::new(None));
let app = ViewportRecorder { seen: seen.clone() };
let _driver = GtkDriver::new(app, 200, 60);
let vp = seen.get().expect("setup() should have run");
assert_eq!(
(vp.width, vp.height),
(200.0, 60.0),
"setup() must see the driver's real size, not a zeroed default"
);
}
const STACKED_ROW_H: f32 = 20.0;
struct StackedRowsApp;
impl AppLogic for StackedRowsApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
for (i, label) in ["row zero", "row one"].into_iter().enumerate() {
backend.draw_status_bar_interactive(
Rect::new(0.0, i as f32 * STACKED_ROW_H, 200.0, STACKED_ROW_H),
&StatusBar {
id: WidgetId::new(format!("row-{i}")),
left_segments: vec![StatusBarSegment {
text: label.to_string(),
fg: Color::rgb(255, 255, 255),
bg: Color::rgb(20, 20, 20),
bold: false,
action_id: None,
}],
right_segments: vec![],
},
&crate::InteractionState::new(),
);
}
}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
#[test]
fn find_bounds_reports_each_stacked_rows_own_absolute_y() {
let driver = GtkDriver::new(StackedRowsApp, 200, 60);
let row0 = driver
.find_bounds("row zero")
.expect("row zero should be painted");
let row1 = driver
.find_bounds("row one")
.expect("row one should be painted");
assert_eq!(row0.y, 0.0, "row 0 should record at its own rect's y");
assert_eq!(
row1.y, STACKED_ROW_H,
"row 1 should record at its own rect's y, not row 0's"
);
}
#[test]
fn app_mut_allows_direct_state_mutation() {
let mut driver = GtkDriver::new(ToggleStatusBarApp { on: false }, W, H);
assert!(!driver.app().on);
driver.app_mut().on = true;
assert!(
driver.app().on,
"app_mut should mutate the wrapped app in place"
);
}
#[test]
fn conformance_driver_new_fixture_and_click_text_at_round_trip() {
use crate::testing::{Anchor, ConformanceDriver, LogicalViewport};
let mut driver: GtkDriver<ToggleStatusBarApp> = GtkDriver::new_fixture(
ToggleStatusBarApp { on: false },
LogicalViewport::new(30, 5),
);
assert!(!ConformanceDriver::exited(&driver));
assert!(ConformanceDriver::screen_has(&driver, "Toggle"));
ConformanceDriver::click_text_at(&mut driver, "Toggle", Anchor::Center);
assert!(
driver.app().on,
"click_text_at(Anchor::Center) should activate the toggle segment \
exactly like the existing find()+click() path does"
);
}
use crate::primitives::tab_bar::{TabBar, TabItem};
const TAB_BAR_W: i32 = 300;
const TAB_BAR_H: i32 = 30;
struct InteractiveTabBarApp {
active: usize,
closed_idx: Option<usize>,
ui_font: Option<&'static str>,
}
impl InteractiveTabBarApp {
const RECT: Rect = Rect::new(0.0, 0.0, TAB_BAR_W as f32, TAB_BAR_H as f32);
fn new(active: usize) -> Self {
Self {
active,
closed_idx: None,
ui_font: None,
}
}
fn with_ui_font(font: &'static str) -> Self {
Self {
ui_font: Some(font),
..Self::new(0)
}
}
fn bar(&self) -> TabBar {
TabBar {
id: WidgetId::new("tabs"),
tabs: (0..3)
.map(|i| TabItem {
label: format!("tab{i}"),
is_active: i == self.active,
is_dirty: false,
is_preview: false,
is_closable: true,
})
.collect(),
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
}
}
}
impl AppLogic for InteractiveTabBarApp {
type AreaId = ();
fn setup(&mut self, backend: &mut dyn Backend) {
if let Some(font) = self.ui_font {
backend.set_ui_font(font);
}
}
#[allow(deprecated)] fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_tab_bar(Self::RECT, &self.bar(), None);
}
#[allow(deprecated)] fn handle(&mut self, event: UiEvent, backend: &mut dyn Backend) -> Reaction {
let UiEvent::MouseDown { position, .. } = event else {
return Reaction::Continue;
};
let hits = backend.tab_bar_layout(Self::RECT, &self.bar());
let x = position.x as f64;
for (i, close) in hits.close_bounds.iter().enumerate() {
if let Some((sx, ex)) = close {
if x >= *sx && x < *ex {
self.closed_idx = Some(i);
return Reaction::Redraw;
}
}
}
for (i, (sx, ex)) in hits.slot_positions.iter().enumerate() {
if x >= *sx && x < *ex {
self.active = i;
return Reaction::Redraw;
}
}
Reaction::Continue
}
}
#[test]
fn tab_center_click_activates_target_tab() {
let app = InteractiveTabBarApp::new(0);
let mut driver = GtkDriver::new(app, TAB_BAR_W, TAB_BAR_H);
let bar_id = WidgetId::new("tabs");
assert_eq!(driver.app().active, 0);
let (x, y) = driver
.tab_center(&bar_id, 1)
.expect("tab 1 should be visible on a wide bar with 3 short tabs");
driver.click(x, y);
assert_eq!(
driver.app().active,
1,
"clicking tab 1's center should activate it"
);
}
#[test]
#[allow(deprecated)] fn tab_bar_layout_twin_matches_painted_geometry_headless() {
let app = InteractiveTabBarApp::new(0);
let mut driver = GtkDriver::new(app, TAB_BAR_W, TAB_BAR_H);
let bar = driver.app().bar();
let (rect, painted) = driver
.core
.backend()
.cached_tab_bar_layout(&bar.id)
.map(|(r, l)| (r, l.clone()))
.expect("the first frame painted the tab bar");
let hits = crate::Backend::tab_bar_layout(driver.core.backend_mut(), rect, &bar);
for vt in &painted.visible_tabs {
let painted_slot = (
(rect.x + vt.bounds.x) as f64,
(rect.x + vt.bounds.x + vt.bounds.width) as f64,
);
assert_eq!(
hits.slot_positions[vt.tab_idx], painted_slot,
"tab {} body: click-time region must equal the painted one",
vt.tab_idx
);
let painted_close = vt
.close_bounds
.map(|cb| ((rect.x + cb.x) as f64, (rect.x + cb.x + cb.width) as f64));
assert_eq!(
hits.close_bounds[vt.tab_idx], painted_close,
"tab {} close button: click-time region must equal the painted one",
vt.tab_idx
);
}
}
#[test]
fn resolve_tab_bar_layout_matches_painted_geometry_headless() {
let app = InteractiveTabBarApp::new(0);
let mut driver = GtkDriver::new(app, TAB_BAR_W, TAB_BAR_H);
let bar = driver.app().bar();
let (rect, painted) = driver
.core
.backend()
.cached_tab_bar_layout(&bar.id)
.map(|(r, l)| (r, l.clone()))
.expect("the first frame painted the tab bar");
let resolved =
crate::Backend::resolve_tab_bar_layout(driver.core.backend_mut(), rect, &bar);
assert_eq!(
resolved, painted,
"`resolve_tab_bar_layout` must report exactly the `TabBarLayout` \
the bar was last painted with"
);
}
#[test]
fn tab_close_center_click_closes_target_tab_not_just_activates() {
let app = InteractiveTabBarApp::new(0);
let mut driver = GtkDriver::new(app, TAB_BAR_W, TAB_BAR_H);
let bar_id = WidgetId::new("tabs");
let (x, y) = driver
.tab_close_center(&bar_id, 1)
.expect("tab 1 is closable and should have a close button");
driver.click(x, y);
assert_eq!(
driver.app().closed_idx,
Some(1),
"clicking tab 1's close button should close it"
);
assert_ne!(
driver.app().active,
1,
"closing tab 1 must not merely activate it — active should stay at its prior value"
);
}
#[test]
fn tab_close_center_click_closes_target_tab_at_any_ui_font() {
for font in ["Sans 6", "Sans 20"] {
let mut driver = GtkDriver::new(
InteractiveTabBarApp::with_ui_font(font),
TAB_BAR_W,
TAB_BAR_H,
);
let bar_id = WidgetId::new("tabs");
let (x, y) = driver
.tab_close_center(&bar_id, 1)
.unwrap_or_else(|| panic!("tab 1 should have a close button at {font:?}"));
driver.click(x, y);
assert_eq!(
driver.app().closed_idx,
Some(1),
"at ui_font {font:?}, clicking tab 1's close button should close it"
);
assert_ne!(
driver.app().active,
1,
"at ui_font {font:?}, closing tab 1 must not merely activate it"
);
}
}
#[test]
fn tab_close_center_none_when_tab_not_closable() {
struct NonClosableTabBarApp;
impl AppLogic for NonClosableTabBarApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_tab_bar(
Rect::new(0.0, 0.0, TAB_BAR_W as f32, TAB_BAR_H as f32),
&TabBar {
id: WidgetId::new("tabs"),
tabs: vec![TabItem {
label: "a".to_string(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: false,
}],
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
},
None,
);
}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
let driver = GtkDriver::new(NonClosableTabBarApp, TAB_BAR_W, TAB_BAR_H);
let bar_id = WidgetId::new("tabs");
assert!(
driver.tab_center(&bar_id, 0).is_some(),
"the tab itself is visible"
);
assert!(
driver.tab_close_center(&bar_id, 0).is_none(),
"a non-closable tab drew no close button, so its center must be None"
);
}
#[test]
fn tab_center_none_when_scrolled_out_of_view() {
struct ScrolledTabBarApp;
impl AppLogic for ScrolledTabBarApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_tab_bar(
Rect::new(0.0, 0.0, TAB_BAR_W as f32, TAB_BAR_H as f32),
&TabBar {
id: WidgetId::new("tabs"),
tabs: vec![
TabItem {
label: "a".repeat(80),
is_active: false,
is_dirty: false,
is_preview: false,
is_closable: true,
},
TabItem {
label: "b".to_string(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: true,
},
TabItem {
label: "c".to_string(),
is_active: false,
is_dirty: false,
is_preview: false,
is_closable: true,
},
],
scroll_offset: 1,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
},
None,
);
}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
let driver = GtkDriver::new(ScrolledTabBarApp, TAB_BAR_W, TAB_BAR_H);
let bar_id = WidgetId::new("tabs");
assert!(
driver.tab_center(&bar_id, 0).is_none(),
"tab 0 is scrolled out of view behind scroll_offset=1"
);
assert!(driver.tab_close_center(&bar_id, 0).is_none());
assert!(
driver.tab_center(&bar_id, 1).is_some(),
"tab 1 (the scroll target) should still be visible"
);
}
}