use crate::compat::{Rc, RefCell};
use crate::core::{Color, HorizontalAlignment, Point, Rect, TextDirection};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::{GenericSignal, Signal1};
use crate::widget::capability::coercion::{
expect_string, expect_text_direction, text_direction_to_str,
};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::role_font;
use crate::widget::{BaseWidget, Draw, IconName, IconSource, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const APP_BAR_LEADING_ZONE: i32 = 48;
const APP_BAR_TRAILING_ZONE: i32 = 80;
const APP_BAR_ARROW_INSET: i32 = 12;
const APP_BAR_TITLE_LEADING_RESERVE: i32 = 40;
const APP_BAR_TITLE_TRAILING_RESERVE: i32 = 80;
const APP_BAR_TITLE_BARE_RESERVE: i32 = 16;
const APP_BAR_ACTION_INSET: i32 = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TapTarget {
Back,
Leading,
Action,
ActionAt(usize),
Overflow,
}
const APP_BAR_ACTION_MIN_WIDTH: i32 = 48;
const APP_BAR_ACTION_GAP: i32 = 4;
struct ActionBoxes {
boxes: Vec<(usize, Rect)>,
overflow: Option<Rect>,
}
struct ActionBoxesCache {
revision: u32,
font: crate::core::Font,
band: Rect,
boxes: Rc<ActionBoxes>,
}
const APP_BAR_MIN_HEIGHT: u32 = 56;
pub struct AppBar {
base: BaseWidget,
title: String,
show_back: bool,
action_text: String,
direction: TextDirection,
pub back_pressed: GenericSignal,
pub action_pressed: GenericSignal,
pub action_index_pressed: Signal1<usize>,
pub action_overflow_pressed: GenericSignal,
actions: Vec<String>,
action_icon_sources: Vec<Option<IconSource>>,
leading_icon: Option<IconSource>,
actions_revision: u32,
action_boxes_cache: RefCell<Option<ActionBoxesCache>>,
pub leading_pressed: GenericSignal,
bottom: Option<Box<dyn Widget>>,
pressed: Option<TapTarget>,
}
impl AppBar {
pub fn new(title: &str, geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::AppBar, geometry, "AppBar"),
title: title.to_string(),
show_back: false,
action_text: String::new(),
direction: TextDirection::default(),
back_pressed: GenericSignal::new(),
action_pressed: GenericSignal::new(),
action_index_pressed: Signal1::new(),
action_overflow_pressed: GenericSignal::new(),
actions: Vec::new(),
action_icon_sources: Vec::new(),
leading_icon: None,
actions_revision: 0,
action_boxes_cache: RefCell::new(None),
leading_pressed: GenericSignal::new(),
bottom: None,
pressed: None,
}
}
pub fn set_title(&mut self, title: &str) {
if self.title == title {
return;
}
self.title = title.to_string();
self.base.request_redraw();
}
pub fn title(&self) -> &str {
&self.title
}
pub fn set_show_back(&mut self, show_back: bool) {
if self.show_back == show_back {
return;
}
self.show_back = show_back;
self.base.request_redraw();
}
pub fn show_back(&self) -> bool {
self.show_back
}
pub fn set_action_text(&mut self, text: &str) {
if self.action_text == text {
return;
}
self.action_text = text.to_string();
self.base.request_redraw();
}
pub fn action_text(&self) -> &str {
&self.action_text
}
pub fn direction(&self) -> TextDirection {
self.direction
}
pub fn set_actions(&mut self, actions: Vec<String>) {
if self.actions == actions {
return;
}
self.actions = actions;
self.actions_revision = self.actions_revision.wrapping_add(1);
self.base.request_redraw();
}
pub fn actions(&self) -> &[String] {
&self.actions
}
pub fn set_action_icon_sources(&mut self, sources: Vec<Option<IconSource>>) {
if self.action_icon_sources == sources {
return;
}
self.action_icon_sources = sources;
self.base.request_redraw();
}
pub fn action_icon_sources(&self) -> &[Option<IconSource>] {
&self.action_icon_sources
}
pub fn set_leading_icon(&mut self, icon: Option<IconSource>) {
if self.leading_icon == icon {
return;
}
self.leading_icon = icon;
self.base.request_redraw();
}
pub fn leading_icon(&self) -> Option<IconSource> {
self.leading_icon
}
pub fn set_bottom_widget(&mut self, widget: Option<Box<dyn Widget>>) {
self.bottom = widget;
self.base.request_redraw();
}
pub fn bottom_widget(&self) -> Option<&dyn Widget> {
self.bottom.as_deref()
}
pub fn preferred_height(&self) -> u32 {
APP_BAR_MIN_HEIGHT + self.bottom_height()
}
fn bottom_height(&self) -> u32 {
let Some(bottom) = self.bottom.as_deref() else {
return 0;
};
let wanted = bottom.size_hint().height;
let room = self.geometry().height.saturating_sub(APP_BAR_MIN_HEIGHT);
wanted.min(room)
}
fn main_band(&self) -> Rect {
let rect = self.geometry();
Rect::new(rect.x, rect.y, rect.width, rect.height.saturating_sub(self.bottom_height()))
}
fn action_font(&self) -> crate::core::Font {
let size = (self.main_band().height as f32 * 0.32).clamp(12.0, 18.0);
role_font(self.base.style(), size)
}
fn action_boxes(&self) -> Rc<ActionBoxes> {
let band = self.trailing_zone();
let font = self.action_font();
if let Some(cache) = self.action_boxes_cache.borrow().as_ref() {
if cache.revision == self.actions_revision && cache.font == font && cache.band == band {
return Rc::clone(&cache.boxes);
}
}
if self.actions.is_empty() || band.width == 0 {
let empty = Rc::new(ActionBoxes { boxes: Vec::new(), overflow: None });
*self.action_boxes_cache.borrow_mut() = Some(ActionBoxesCache {
revision: self.actions_revision,
font,
band,
boxes: Rc::clone(&empty),
});
return empty;
}
let mut cursor = band.right();
let mut boxes: Vec<(usize, Rect)> = Vec::new();
let mut overflow = None;
for (index, label) in self.actions.iter().enumerate() {
let width = ((crate::widget::metrics::estimate_text_width(label, &font, 1.0) as i32)
+ 16)
.max(APP_BAR_ACTION_MIN_WIDTH);
let left = cursor - width;
if left < band.x + APP_BAR_ACTION_MIN_WIDTH + APP_BAR_ACTION_GAP {
overflow =
Some(Rect::new(band.x, band.y, APP_BAR_ACTION_MIN_WIDTH as u32, band.height));
break;
}
cursor = left;
boxes.push((index, Rect::new(cursor, band.y, width as u32, band.height)));
cursor -= APP_BAR_ACTION_GAP;
}
let boxes = Rc::new(ActionBoxes { boxes, overflow });
*self.action_boxes_cache.borrow_mut() = Some(ActionBoxesCache {
revision: self.actions_revision,
font,
band,
boxes: Rc::clone(&boxes),
});
boxes
}
pub fn set_direction(&mut self, direction: TextDirection) {
if self.direction != direction {
self.direction = direction;
self.base.request_redraw();
}
}
fn leading_zone(&self) -> Rect {
let rect = self.main_band();
let x = if self.direction.is_right_to_left() {
rect.right() - APP_BAR_LEADING_ZONE
} else {
rect.x
};
Rect::new(x, rect.y, APP_BAR_LEADING_ZONE as u32, rect.height)
}
fn trailing_zone(&self) -> Rect {
let rect = self.main_band();
let x = if self.direction.is_right_to_left() {
rect.x
} else {
rect.right() - APP_BAR_TRAILING_ZONE
};
Rect::new(x, rect.y, APP_BAR_TRAILING_ZONE as u32, rect.height)
}
fn tap_target_at(&self, pos: Point) -> Option<TapTarget> {
let boxes = self.action_boxes();
for (index, box_rect) in boxes.boxes.iter() {
if box_rect.contains_point(pos) {
return Some(TapTarget::ActionAt(*index));
}
}
if boxes.overflow.is_some_and(|box_rect| box_rect.contains_point(pos)) {
return Some(TapTarget::Overflow);
}
let leading_present = self.show_back || self.leading_icon.is_some();
if leading_present && self.leading_zone().contains_point(pos) {
return Some(if self.leading_icon.is_some() {
TapTarget::Leading
} else {
TapTarget::Back
});
}
if !self.action_text.is_empty()
&& self.actions.is_empty()
&& self.trailing_zone().contains_point(pos)
{
return Some(TapTarget::Action);
}
if self.show_back {
return Some(TapTarget::Back);
}
None
}
}
impl Widget for AppBar {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(400, 56)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"back_pressed" => Some(EventSignalRef::unit("back_pressed", &self.back_pressed)),
"action_pressed" => Some(EventSignalRef::unit("action_pressed", &self.action_pressed)),
"action_index_pressed" => Some(EventSignalRef::mapped(
"action_index_pressed",
&self.action_index_pressed,
|v| crate::widget::capability::CapabilityValue::UInt(*v as u64),
)),
"action_overflow_pressed" => {
Some(EventSignalRef::unit("action_overflow_pressed", &self.action_overflow_pressed))
}
"leading_pressed" => {
Some(EventSignalRef::unit("leading_pressed", &self.leading_pressed))
}
_ => None,
}
}
}
impl WidgetProperties for AppBar {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"title" => Ok(CapabilityValue::String(self.title().to_string())),
"direction" => {
Ok(CapabilityValue::String(text_direction_to_str(self.direction()).to_string()))
}
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"title" => {
self.set_title(&expect_string(value)?);
Ok(())
}
"direction" => {
self.set_direction(expect_text_direction(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["title", "direction", BASE_PROPERTY_NAMES]
}
}
impl Draw for AppBar {
fn draw(&mut self, context: &mut RenderContext) {
let full_rect = self.geometry();
let rect = self.main_band();
let is_enabled = self.base.is_enabled();
let bar_height = rect.height;
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("app_bar");
let background = style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(if is_enabled {
Color::rgba(248, 248, 250, 255)
} else {
Color::DISABLED_BACKGROUND
});
let border_color = if is_enabled {
style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.unwrap_or(Color::DIVIDER)
} else {
background.blend(
&background.contrast_color(),
crate::widget::BaseWidget::disabled_ink_weight(),
)
};
let text_color = if is_enabled {
style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::FOREGROUND)
} else {
background.blend(
&background.contrast_color(),
crate::widget::BaseWidget::disabled_ink_weight(),
)
};
context.face_with_gradient_corners(
rect,
background,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().resolved_corner_radii().to_array(),
Color::BLACK,
);
let border_y = rect.y + bar_height as i32 - 1;
context.draw_line_stroke(
Point::new(rect.x, border_y),
Point::new(rect.x + rect.width as i32, border_y),
border_color,
1,
);
let text_scale = crate::platform::profile::text_scale();
let title_font_size =
(bar_height as f32 * 0.38 * text_scale).clamp(14.0, 22.0 * text_scale);
let action_font_size =
(bar_height as f32 * 0.32 * text_scale).clamp(12.0, 18.0 * text_scale);
let icon_side = ((action_font_size + 2.0).round() as i32).clamp(16, 28);
if self.show_back || self.leading_icon.is_some() {
let back_x = if self.direction.is_right_to_left() {
rect.right() - APP_BAR_ARROW_INSET - icon_side
} else {
rect.x + APP_BAR_ARROW_INSET
};
let back_y = rect.y + (bar_height as i32 - icon_side) / 2;
let back_rect = Rect::new(back_x, back_y, icon_side as u32, icon_side as u32);
match self.leading_icon {
Some(source) => {
if let Err(error) =
crate::widget::paint_icon(context, source.into(), back_rect, text_color)
{
log::warn!("app bar leading icon could not be painted: {error:?}");
}
}
None => {
crate::widget::draw_icon_at(
context,
back_rect,
text_color,
IconName::ArrowLeft,
);
}
}
}
if !self.title.is_empty() {
let title_font = role_font(style, title_font_size);
let metrics = crate::widget::base_widgets::measure_cache::measure_text(
context,
&self.title,
&title_font,
);
let (leading_reserve, trailing_reserve) = (
if self.show_back {
APP_BAR_TITLE_LEADING_RESERVE
} else {
APP_BAR_TITLE_BARE_RESERVE
},
if self.action_text.is_empty() {
APP_BAR_TITLE_BARE_RESERVE
} else {
APP_BAR_TITLE_TRAILING_RESERVE
},
);
let title_width = metrics.width as i32;
let overflow_x = if self.direction.is_right_to_left() {
rect.right() - leading_reserve - title_width
} else {
rect.x + leading_reserve
};
let available_width = rect.width as i32 - leading_reserve - trailing_reserve;
let title_x = if title_width > available_width {
overflow_x
} else {
rect.x + (rect.width as i32 / 2) - (title_width / 2)
};
let title_y = rect.y + (bar_height as i32 - metrics.height as i32) / 2;
let fit_inset = crate::render::TEXT_FIT_MARGIN as i32;
context.draw_text_fitted_cow(
Rect::new(
title_x - fit_inset,
title_y,
(available_width + 2 * fit_inset).max(1) as u32,
metrics.height.max(1),
),
&self.title,
&title_font,
text_color,
HorizontalAlignment::Left,
);
}
if !self.action_text.is_empty() && self.actions.is_empty() {
let action_font = role_font(style, action_font_size);
let metrics = crate::widget::base_widgets::measure_cache::measure_text(
context,
&self.action_text,
&action_font,
);
let action_x = if self.direction.is_right_to_left() {
rect.x + APP_BAR_ACTION_INSET
} else {
rect.right() - metrics.width as i32 - APP_BAR_ACTION_INSET
};
let action_y = rect.y + (bar_height as i32 - metrics.height as i32) / 2;
let fit_inset = crate::render::TEXT_FIT_MARGIN as i32;
let action_color = if is_enabled {
text_color.blend(&background, 0.25)
} else {
background.blend(
&background.contrast_color(),
crate::widget::BaseWidget::disabled_ink_weight(),
)
};
context.draw_text_fitted_cow(
Rect::new(
action_x - fit_inset,
action_y,
(metrics.width as i32 + 2 * fit_inset).max(1) as u32,
metrics.height.max(1),
),
&self.action_text,
&action_font,
action_color,
HorizontalAlignment::Left,
);
}
let boxes = self.action_boxes();
if !boxes.boxes.is_empty() || boxes.overflow.is_some() {
let action_font = role_font(style, action_font_size);
let action_color = if is_enabled {
text_color.blend(&background, 0.25)
} else {
background.blend(
&background.contrast_color(),
crate::widget::BaseWidget::disabled_ink_weight(),
)
};
for (index, box_rect) in boxes.boxes.iter() {
if let Some(Some(source)) = self.action_icon_sources.get(*index) {
let side =
action_font.size_u32().clamp(1, box_rect.width.min(box_rect.height).max(1));
let icon_rect = Rect::new(
box_rect.x + (box_rect.width.saturating_sub(side) / 2) as i32,
box_rect.y + (box_rect.height.saturating_sub(side) / 2) as i32,
side,
side,
);
if let Err(error) = crate::widget::paint_icon(
context,
(*source).into(),
icon_rect,
action_color,
) {
log::warn!("app bar action icon could not be painted: {error:?}");
}
continue;
}
if let Some(label) = self.actions.get(*index) {
context.draw_text_fitted_cow(
context.text_line(*box_rect, &action_font),
label,
&action_font,
action_color,
HorizontalAlignment::Center,
);
}
}
if let Some(box_rect) = boxes.overflow {
context.draw_text_fitted_cow(
context.text_line(box_rect, &action_font),
"\u{2026}",
&action_font,
action_color,
HorizontalAlignment::Center,
);
}
}
if self.bottom.is_some() {
let band_height = self.bottom_height();
if band_height > 0 {
let band = Rect::new(
full_rect.x,
full_rect.y + full_rect.height as i32 - band_height as i32,
full_rect.width,
band_height,
);
if let Some(bottom) = self.bottom.as_deref_mut() {
bottom.set_geometry(band);
if let Some(drawable) = crate::widget::draw_bridge::draw_of(bottom) {
drawable.draw(context);
}
}
}
}
}
}
impl EventHandler for AppBar {
fn handle_event(&mut self, event: &Event) {
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button, .. } => {
if *button != 1 {
return;
}
let target = self.tap_target_at(*pos);
if self.pressed != target {
self.pressed = target;
self.base.request_redraw();
}
}
Event::MouseRelease { pos, button } => {
if *button != 1 {
return;
}
let Some(armed) = self.pressed.take() else {
return;
};
self.base.request_redraw();
if self.tap_target_at(*pos) != Some(armed) {
return;
}
match armed {
TapTarget::Back => self.back_pressed.emit(),
TapTarget::Leading => self.leading_pressed.emit(),
TapTarget::Action => self.action_pressed.emit(),
TapTarget::ActionAt(index) => {
self.action_index_pressed.emit(index);
self.action_pressed.emit();
}
TapTarget::Overflow => self.action_overflow_pressed.emit(),
}
}
_ => {
self.base.handle_event(event);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc,
};
fn make_app_bar() -> AppBar {
AppBar::new("Home", Rect::new(0, 0, 375, 56))
}
#[test]
fn app_bar_default_creation() {
let bar = make_app_bar();
assert_eq!(bar.kind(), WidgetKind::AppBar);
assert_eq!(bar.title(), "Home");
assert!(!bar.show_back());
assert_eq!(bar.action_text(), "");
assert!(bar.is_visible());
assert!(bar.is_enabled());
assert_eq!(bar.geometry(), Rect::new(0, 0, 375, 56));
}
#[test]
fn app_bar_title_accessors() {
let mut bar = make_app_bar();
assert_eq!(bar.title(), "Home");
bar.set_title("Settings");
assert_eq!(bar.title(), "Settings");
bar.set_title("");
assert_eq!(bar.title(), "");
}
#[test]
fn app_bar_show_back_accessors() {
let mut bar = make_app_bar();
assert!(!bar.show_back());
bar.set_show_back(true);
assert!(bar.show_back());
bar.set_show_back(false);
assert!(!bar.show_back());
}
#[test]
fn app_bar_action_text_accessors() {
let mut bar = make_app_bar();
assert_eq!(bar.action_text(), "");
bar.set_action_text("Save");
assert_eq!(bar.action_text(), "Save");
bar.set_action_text("");
assert_eq!(bar.action_text(), "");
}
#[test]
fn app_bar_back_pressed_signal_emits_on_left_tap() {
let mut bar = make_app_bar();
bar.set_show_back(true);
let fired = Arc::new(AtomicUsize::new(0));
let f = fired.clone();
bar.back_pressed.connect(move || {
f.fetch_add(1, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(10, 28), button: 1, modifiers: 0 });
assert_eq!(fired.load(Ordering::SeqCst), 0, "a press alone must not emit");
bar.handle_event(&Event::MouseRelease { pos: Point::new(10, 28), button: 1 });
assert_eq!(fired.load(Ordering::SeqCst), 1);
}
#[test]
fn app_bar_back_pressed_emits_on_center_tap_when_back_shown() {
let mut bar = make_app_bar();
bar.set_show_back(true);
let fired = Arc::new(AtomicUsize::new(0));
let f = fired.clone();
bar.back_pressed.connect(move || {
f.fetch_add(1, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(188, 28), button: 1, modifiers: 0 });
assert_eq!(fired.load(Ordering::SeqCst), 0, "a press alone must not emit");
bar.handle_event(&Event::MouseRelease { pos: Point::new(188, 28), button: 1 });
assert_eq!(fired.load(Ordering::SeqCst), 1);
}
#[test]
fn app_bar_action_pressed_signal_emits_on_right_tap() {
let mut bar = make_app_bar();
bar.set_action_text("Save");
let fired = Arc::new(AtomicUsize::new(0));
let f = fired.clone();
bar.action_pressed.connect(move || {
f.fetch_add(1, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(340, 28), button: 1, modifiers: 0 });
assert_eq!(fired.load(Ordering::SeqCst), 0, "a press alone must not emit");
bar.handle_event(&Event::MouseRelease { pos: Point::new(340, 28), button: 1 });
assert_eq!(fired.load(Ordering::SeqCst), 1);
}
#[test]
fn app_bar_action_pressed_not_emitted_on_center_tap() {
let mut bar = make_app_bar();
bar.set_action_text("Save");
bar.set_show_back(true);
let action_fired = Arc::new(AtomicUsize::new(0));
let a = action_fired.clone();
bar.action_pressed.connect(move || {
a.fetch_add(1, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(188, 28), button: 1, modifiers: 0 });
bar.handle_event(&Event::MouseRelease { pos: Point::new(188, 28), button: 1 });
assert_eq!(action_fired.load(Ordering::SeqCst), 0);
}
#[test]
fn app_bar_disabled_blocks_events() {
let mut bar = make_app_bar();
bar.set_show_back(true);
bar.set_enabled(false);
bar.set_action_text("Save");
let back_fired = Arc::new(AtomicUsize::new(0));
let b = back_fired.clone();
bar.back_pressed.connect(move || {
b.fetch_add(1, Ordering::SeqCst);
});
let action_fired = Arc::new(AtomicUsize::new(0));
let a = action_fired.clone();
bar.action_pressed.connect(move || {
a.fetch_add(1, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(10, 28), button: 1, modifiers: 0 });
bar.handle_event(&Event::MouseRelease { pos: Point::new(10, 28), button: 1 });
assert_eq!(back_fired.load(Ordering::SeqCst), 0);
assert_eq!(action_fired.load(Ordering::SeqCst), 0);
}
#[test]
fn app_bar_svg_output() {
let mut bar = make_app_bar();
let svg = render_to_svg(&mut bar);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("width=\"375\""));
assert!(svg.contains("height=\"56\""));
}
#[test]
fn app_bar_svg_with_back_and_action() {
let mut bar = make_app_bar();
bar.set_show_back(true);
bar.set_action_text("Cancel");
let svg = render_to_svg(&mut bar);
assert!(svg.starts_with("<svg"));
assert!(svg.contains("width=\"375\""));
assert!(svg.contains("height=\"56\""));
}
#[test]
fn app_bar_back_pressed_signal_accessor() {
let bar = make_app_bar();
let signal = &bar.back_pressed;
let fired = Arc::new(AtomicBool::new(false));
let f = fired.clone();
signal.connect(move || {
f.store(true, Ordering::SeqCst);
});
signal.emit();
assert!(fired.load(Ordering::SeqCst));
}
#[test]
fn app_bar_action_pressed_signal_accessor() {
let bar = make_app_bar();
let signal = &bar.action_pressed;
let fired = Arc::new(AtomicBool::new(false));
let f = fired.clone();
signal.connect(move || {
f.store(true, Ordering::SeqCst);
});
signal.emit();
assert!(fired.load(Ordering::SeqCst));
}
#[test]
fn app_bar_other_button_noop() {
let mut bar = make_app_bar();
bar.set_show_back(true);
let fired = Arc::new(AtomicUsize::new(0));
let f = fired.clone();
bar.back_pressed.connect(move || {
f.fetch_add(1, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(10, 28), button: 2, modifiers: 0 });
bar.handle_event(&Event::MouseRelease { pos: Point::new(10, 28), button: 2 });
assert_eq!(fired.load(Ordering::SeqCst), 0);
}
#[test]
fn app_bar_release_commits_a_prior_press_exactly_once() {
let mut bar = make_app_bar();
bar.set_show_back(true);
let fired = Arc::new(AtomicUsize::new(0));
let f = fired.clone();
bar.back_pressed.connect(move || {
f.fetch_add(1, Ordering::SeqCst);
});
bar.handle_event(&Event::MouseRelease { pos: Point::new(10, 28), button: 1 });
assert_eq!(fired.load(Ordering::SeqCst), 0, "a bare release must not emit");
bar.handle_event(&Event::MousePress { pos: Point::new(10, 28), button: 1, modifiers: 0 });
bar.handle_event(&Event::MouseRelease { pos: Point::new(10, 28), button: 1 });
assert_eq!(fired.load(Ordering::SeqCst), 1, "one press+release emits once");
}
#[test]
fn a_right_to_left_bar_mirrors_both_affordances() {
let _theme_guard = crate::style::theme_test_guard();
const INK_INSET_TOLERANCE: i32 = 3;
fn glyph_xs(svg: &str) -> (i32, i32) {
let boxes = crate::widget::svg::text_ink_boxes(svg);
let (left, right) = boxes
.iter()
.fold((i32::MAX, i32::MIN), |(min, max), b| (min.min(b.0), max.max(b.2)));
assert!(left <= right, "the bar painted no ink at all");
(left, right)
}
fn icon_x(svg: &str) -> (i32, i32) {
let (left, _, right, _) =
crate::widget::svg::first_shape_bounds(svg).expect("the arrow icon draws a shape");
(left, right)
}
fn only_back(direction: TextDirection) -> String {
let mut bar = AppBar::new("", Rect::new(0, 0, 400, 56));
bar.set_show_back(true);
bar.set_direction(direction);
render_to_svg(&mut bar)
}
fn only_action(direction: TextDirection) -> String {
let mut bar = AppBar::new("", Rect::new(0, 0, 400, 56));
bar.set_action_text("Save");
bar.set_direction(direction);
render_to_svg(&mut bar)
}
let (ltr_back_left, ltr_back_right) = icon_x(&only_back(TextDirection::LeftToRight));
let (rtl_back_left, _) = icon_x(&only_back(TextDirection::RightToLeft));
assert!(
(APP_BAR_ARROW_INSET..=APP_BAR_ARROW_INSET + 28).contains(<r_back_left)
&& ltr_back_right > ltr_back_left,
"LTR draws the arrow box at the left inset {APP_BAR_ARROW_INSET}; its ink spanned \
{ltr_back_left}..{ltr_back_right}"
);
assert!(
rtl_back_left > 400 / 2,
"RTL must draw the arrow on the right half, drew its ink at {rtl_back_left}"
);
let (_, ltr_action_right) = glyph_xs(&only_action(TextDirection::LeftToRight));
let (rtl_action_left, _) = glyph_xs(&only_action(TextDirection::RightToLeft));
assert!(
ltr_action_right > 400 - APP_BAR_ACTION_INSET - 40,
"LTR must draw the action against the right edge, its ink ended at {ltr_action_right}"
);
assert!(
(rtl_action_left - APP_BAR_ACTION_INSET).abs() <= INK_INSET_TOLERANCE,
"RTL draws the action at the left inset: ink left {rtl_action_left}, inset {APP_BAR_ACTION_INSET}"
);
}
#[test]
fn the_leading_and_trailing_zones_follow_the_direction() {
let mut bar = AppBar::new("Home", Rect::new(0, 0, 400, 56));
bar.set_show_back(true);
bar.set_action_text("Save");
assert_eq!(bar.leading_zone().x, 0);
assert_eq!(bar.trailing_zone().x, 400 - APP_BAR_TRAILING_ZONE);
bar.set_direction(TextDirection::RightToLeft);
assert_eq!(bar.leading_zone().right(), 400);
assert_eq!(bar.leading_zone().width, APP_BAR_LEADING_ZONE as u32);
assert_eq!(bar.trailing_zone().x, 0);
}
#[test]
fn a_mirrored_bars_tap_zones_follow_its_arrow() {
let mut bar = AppBar::new("Home", Rect::new(0, 0, 400, 56));
bar.set_show_back(true);
bar.set_action_text("Save");
bar.set_direction(TextDirection::RightToLeft);
let back = Arc::new(AtomicUsize::new(0));
let b = back.clone();
bar.back_pressed.connect(move || {
b.fetch_add(1, Ordering::SeqCst);
});
let action = Arc::new(AtomicUsize::new(0));
let a = action.clone();
bar.action_pressed.connect(move || {
a.fetch_add(1, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(380, 28), button: 1, modifiers: 0 });
bar.handle_event(&Event::MouseRelease { pos: Point::new(380, 28), button: 1 });
assert_eq!(back.load(Ordering::SeqCst), 1, "tapping the drawn arrow must press back");
assert_eq!(action.load(Ordering::SeqCst), 0, "and must not press the action");
back.store(0, Ordering::SeqCst);
bar.handle_event(&Event::MousePress { pos: Point::new(20, 28), button: 1, modifiers: 0 });
bar.handle_event(&Event::MouseRelease { pos: Point::new(20, 28), button: 1 });
assert_eq!(
action.load(Ordering::SeqCst),
1,
"tapping the drawn action must fire the action"
);
assert_eq!(back.load(Ordering::SeqCst), 0, "and must not press back");
}
#[test]
fn the_default_bar_is_still_left_to_right() {
let _guard = crate::style::theme_test_guard();
let mut untouched = make_app_bar();
untouched.set_show_back(true);
untouched.set_action_text("Save");
let mut ltr = make_app_bar();
ltr.set_show_back(true);
ltr.set_action_text("Save");
ltr.set_direction(TextDirection::LeftToRight);
assert_eq!(render_to_svg(&mut untouched), render_to_svg(&mut ltr));
assert_eq!(
AppBar::new("x", Rect::new(0, 0, 10, 10)).direction(),
TextDirection::LeftToRight
);
}
#[test]
fn the_direction_round_trips_through_the_property_api() {
let mut bar = make_app_bar();
assert_eq!(bar.get("direction").unwrap().as_str(), Some("ltr"));
bar.set("direction", CapabilityValue::String("rtl".to_string())).unwrap();
assert_eq!(bar.direction(), TextDirection::RightToLeft);
assert_eq!(bar.get("direction").unwrap().as_str(), Some("rtl"));
}
#[test]
fn a_crowded_action_list_folds_into_an_overflow_affordance() {
let mut bar = make_app_bar();
let mut actions: Vec<String> = Vec::new();
for index in 0..8 {
actions.push(format!("Action {index}"));
}
bar.set_actions(actions);
assert_eq!(bar.actions().len(), 8);
let fired = Arc::new(AtomicUsize::new(0));
let f = fired.clone();
bar.action_index_pressed.connect(move |index: Arc<usize>| {
f.store(*index, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(370, 28), button: 1, modifiers: 0 });
bar.handle_event(&Event::MouseRelease { pos: Point::new(370, 28), button: 1 });
assert_eq!(fired.load(Ordering::SeqCst), 0, "the trailing edge is action zero");
}
#[test]
fn a_bottom_slot_extends_the_bar_without_moving_the_main_band() {
let mut bar = AppBar::new("Home", Rect::new(0, 0, 375, 96));
bar.set_bottom_widget(Some(Box::new(crate::widget::base_widgets::label::Label::new(
"tabs".to_string(),
Rect::new(0, 0, 375, 40),
))));
assert!(bar.preferred_height() >= 56);
let svg = render_to_svg(&mut bar);
assert!(svg.starts_with("<svg"));
}
#[test]
fn a_custom_leading_icon_replaces_the_back_arrow_and_reports_its_own_signal() {
let mut bar = make_app_bar();
assert_eq!(bar.leading_icon(), None);
bar.set_leading_icon(Some(IconSource::Builtin(IconName::Menu)));
assert_eq!(bar.leading_icon(), Some(IconSource::Builtin(IconName::Menu)));
let leading = Arc::new(AtomicUsize::new(0));
let sink = leading.clone();
bar.leading_pressed.connect(move || {
sink.fetch_add(1, Ordering::SeqCst);
});
let back = Arc::new(AtomicUsize::new(0));
let back_sink = back.clone();
bar.back_pressed.connect(move || {
back_sink.fetch_add(1, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(20, 28), button: 1, modifiers: 0 });
bar.handle_event(&Event::MouseRelease { pos: Point::new(20, 28), button: 1 });
assert_eq!(leading.load(Ordering::SeqCst), 1);
assert_eq!(back.load(Ordering::SeqCst), 0, "the custom icon is not the back affordance");
}
#[test]
fn trailing_actions_accept_icons_without_moving_the_hit_test() {
let mut bar = make_app_bar();
bar.set_actions(vec!["Save".to_string()]);
bar.set_action_icon_sources(vec![Some(IconSource::Builtin(IconName::Save))]);
assert_eq!(bar.action_icon_sources().len(), 1);
let fired = Arc::new(AtomicUsize::new(0));
let sink = fired.clone();
bar.action_index_pressed.connect(move |index: Arc<usize>| {
sink.store(*index, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(370, 28), button: 1, modifiers: 0 });
bar.handle_event(&Event::MouseRelease { pos: Point::new(370, 28), button: 1 });
assert_eq!(fired.load(Ordering::SeqCst), 0);
}
}