use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, TextDirection};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::GenericSignal;
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::{BaseWidget, Draw, 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;
pub struct AppBar {
base: BaseWidget,
title: String,
show_back: bool,
action_text: String,
direction: TextDirection,
pub back_pressed: GenericSignal,
pub action_pressed: GenericSignal,
}
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(),
}
}
pub fn set_title(&mut self, title: &str) {
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) {
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) {
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_direction(&mut self, direction: TextDirection) {
if self.direction != direction {
self.direction = direction;
self.base.request_redraw();
}
}
fn leading_zone(&self) -> Rect {
let rect = self.geometry();
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.geometry();
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)
}
}
impl Widget for AppBar {
fn base(&self) -> &BaseWidget {
&self.base
}
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!();
}
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 rect = self.geometry();
let is_enabled = self.base.is_enabled();
let bar_height = rect.height;
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("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 {
Color::DISABLED_FOREGROUND
};
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 {
Color::DISABLED_FOREGROUND
};
context.fill_rect(rect, background);
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);
if self.show_back {
let back_font = Font::new("sans-serif", action_font_size + 2.0, false, false);
let back_text = "←";
let metrics = context.measure_text(back_text, &back_font);
let back_x = if self.direction.is_right_to_left() {
rect.right() - APP_BAR_ARROW_INSET - metrics.width as i32
} else {
rect.x + APP_BAR_ARROW_INSET
};
let back_y = rect.y + (bar_height as i32 - metrics.height as i32) / 2;
context.draw_text(
Point::new(back_x, back_y),
back_text,
&back_font,
text_color,
HorizontalAlignment::Left,
);
}
if !self.title.is_empty() {
let title_font = Font::new("sans-serif", title_font_size, false, false);
let metrics = context.measure_text(&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;
context.draw_text(
Point::new(title_x, title_y),
&self.title,
&title_font,
text_color,
HorizontalAlignment::Left,
);
}
if !self.action_text.is_empty() {
let action_font = Font::new("sans-serif", action_font_size, false, false);
let metrics = context.measure_text(&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 action_color = if is_enabled {
text_color.blend(&background, 0.25)
} else {
Color::DISABLED_FOREGROUND
};
context.draw_text(
Point::new(action_x, action_y),
&self.action_text,
&action_font,
action_color,
HorizontalAlignment::Left,
);
}
}
}
impl EventHandler for AppBar {
fn handle_event(&mut self, event: &Event) {
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button } | Event::MouseRelease { pos, button } => {
if *button != 1 {
return;
}
if self.show_back && self.leading_zone().contains_point(*pos) {
self.back_pressed.emit();
self.base.request_redraw();
return;
}
if !self.action_text.is_empty() && self.trailing_zone().contains_point(*pos) {
self.action_pressed.emit();
self.base.request_redraw();
return;
}
if self.show_back {
self.back_pressed.emit();
self.base.request_redraw();
}
}
_ => {
self.base.handle_event(event);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
use std::sync::{
atomic::{AtomicBool, 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(AtomicBool::new(false));
let f = fired.clone();
bar.back_pressed.connect(move || {
f.store(true, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(10, 28), button: 1 });
assert!(fired.load(Ordering::SeqCst));
}
#[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(AtomicBool::new(false));
let f = fired.clone();
bar.back_pressed.connect(move || {
f.store(true, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(188, 28), button: 1 });
assert!(fired.load(Ordering::SeqCst));
}
#[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(AtomicBool::new(false));
let f = fired.clone();
bar.action_pressed.connect(move || {
f.store(true, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(340, 28), button: 1 });
assert!(fired.load(Ordering::SeqCst));
}
#[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(AtomicBool::new(false));
let a = action_fired.clone();
bar.action_pressed.connect(move || {
a.store(true, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(188, 28), button: 1 });
assert!(!action_fired.load(Ordering::SeqCst));
}
#[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(AtomicBool::new(false));
let b = back_fired.clone();
bar.back_pressed.connect(move || {
b.store(true, Ordering::SeqCst);
});
let action_fired = Arc::new(AtomicBool::new(false));
let a = action_fired.clone();
bar.action_pressed.connect(move || {
a.store(true, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(10, 28), button: 1 });
assert!(!back_fired.load(Ordering::SeqCst));
assert!(!action_fired.load(Ordering::SeqCst));
}
#[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(AtomicBool::new(false));
let f = fired.clone();
bar.back_pressed.connect(move || {
f.store(true, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(10, 28), button: 2 });
assert!(!fired.load(Ordering::SeqCst));
}
#[test]
fn app_bar_release_also_emits() {
let mut bar = make_app_bar();
bar.set_show_back(true);
let fired = Arc::new(AtomicBool::new(false));
let f = fired.clone();
bar.back_pressed.connect(move || {
f.store(true, Ordering::SeqCst);
});
bar.handle_event(&Event::MouseRelease { pos: Point::new(10, 28), button: 1 });
assert!(fired.load(Ordering::SeqCst));
}
#[test]
fn a_right_to_left_bar_mirrors_both_affordances() {
let _theme_guard = crate::style::theme_test_guard();
fn glyph_xs(svg: &str) -> (i32, i32) {
let mut min = i32::MAX;
let mut max = i32::MIN;
for command in svg.split("M").skip(1) {
let x = command
.split_whitespace()
.next()
.and_then(|token| token.parse::<f32>().ok())
.map(|v| v as i32);
if let Some(x) = x {
min = min.min(x);
max = max.max(x);
}
}
assert!(min <= max, "the bar painted no ink at all");
(min, max)
}
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, _) = glyph_xs(&only_back(TextDirection::LeftToRight));
let (rtl_back_left, _) = glyph_xs(&only_back(TextDirection::RightToLeft));
assert_eq!(ltr_back_left, APP_BAR_ARROW_INSET, "LTR draws the arrow at the left inset");
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_eq!(rtl_action_left, APP_BAR_ACTION_INSET, "RTL draws the action at the left 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(AtomicBool::new(false));
let b = back.clone();
bar.back_pressed.connect(move || {
b.store(true, Ordering::SeqCst);
});
let action = Arc::new(AtomicBool::new(false));
let a = action.clone();
bar.action_pressed.connect(move || {
a.store(true, Ordering::SeqCst);
});
bar.handle_event(&Event::MousePress { pos: Point::new(380, 28), button: 1 });
assert!(back.load(Ordering::SeqCst), "tapping the drawn arrow must press back");
assert!(!action.load(Ordering::SeqCst), "and must not press the action");
back.store(false, Ordering::SeqCst);
bar.handle_event(&Event::MousePress { pos: Point::new(20, 28), button: 1 });
assert!(action.load(Ordering::SeqCst), "tapping the drawn action must fire the action");
assert!(!back.load(Ordering::SeqCst), "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"));
}
}