use crate::compat::Cow;
use crate::core::{HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::{GenericSignal, Signal1};
use crate::widget::capability::coercion::{expect_bool, expect_string};
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::{profile_metrics, role_font, ControlMetricsProfile};
use crate::widget::text_utils::elide_to_width;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use super::{
draw_focus_ring, palette, MAC_TITLE_BAR_HEIGHT, MAC_TRAFFIC_LIGHT_GAP, MAC_TRAFFIC_LIGHT_INSET,
};
const FULLSCREEN_DRAG_BAND: u32 = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindowChromeButton {
Close,
Minimize,
Zoom,
}
impl WindowChromeButton {
const ALL: [Self; 3] = [Self::Close, Self::Minimize, Self::Zoom];
fn index(self) -> i32 {
match self {
Self::Close => 0,
Self::Minimize => 1,
Self::Zoom => 2,
}
}
}
pub struct MacWindowChrome {
base: BaseWidget,
title: String,
buttons_visible: bool,
draggable: bool,
bar_height: u32,
hovered: Option<WindowChromeButton>,
pressed: Option<WindowChromeButton>,
pub close_requested: GenericSignal,
pub minimize_requested: GenericSignal,
pub zoom_requested: GenericSignal,
pub fullscreen_requested: GenericSignal,
pub window_focus_changed: Signal1<bool>,
fullscreen: bool,
document_edited: bool,
focused: bool,
}
impl MacWindowChrome {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Window, geometry, "MacWindowChrome"),
title: "Window".to_string(),
buttons_visible: true,
draggable: true,
bar_height: MAC_TITLE_BAR_HEIGHT,
hovered: None,
pressed: None,
close_requested: GenericSignal::new(),
minimize_requested: GenericSignal::new(),
zoom_requested: GenericSignal::new(),
fullscreen_requested: GenericSignal::new(),
window_focus_changed: Signal1::new(),
fullscreen: false,
document_edited: false,
focused: true,
}
}
pub fn title(&self) -> &str {
&self.title
}
pub fn set_title(&mut self, title: impl Into<String>) {
let title = title.into();
if title == self.title {
return;
}
self.title = title;
self.base.request_redraw();
}
pub fn buttons_visible(&self) -> bool {
self.buttons_visible
}
pub fn set_buttons_visible(&mut self, visible: bool) {
if visible == self.buttons_visible {
return;
}
self.buttons_visible = visible;
if !visible {
self.hovered = None;
self.pressed = None;
}
self.base.request_redraw();
}
pub fn draggable(&self) -> bool {
self.draggable
}
pub fn set_draggable(&mut self, draggable: bool) {
if draggable == self.draggable {
return;
}
self.draggable = draggable;
}
pub fn is_fullscreen(&self) -> bool {
self.fullscreen
}
pub fn set_fullscreen(&mut self, fullscreen: bool) {
if self.fullscreen == fullscreen {
return;
}
self.fullscreen = fullscreen;
self.base.request_redraw();
}
pub fn request_fullscreen(&mut self) {
self.fullscreen_requested.emit();
}
pub fn is_document_edited(&self) -> bool {
self.document_edited
}
pub fn set_document_edited(&mut self, edited: bool) {
if self.document_edited == edited {
return;
}
self.document_edited = edited;
self.base.request_redraw();
}
pub fn is_focused(&self) -> bool {
self.focused
}
pub fn set_focused(&mut self, focused: bool) {
if self.focused == focused {
return;
}
self.focused = focused;
self.window_focus_changed.emit(focused);
self.base.request_redraw();
}
pub fn bar_height(&self) -> u32 {
self.bar_height
}
pub fn bar_rect(&self) -> Rect {
let rect = self.geometry();
Rect::new(rect.x, rect.y, rect.width, self.bar_height.min(rect.height))
}
pub fn traffic_light_diameter(&self) -> u32 {
2 * profile_metrics(ControlMetricsProfile::Macos).button_radius
}
pub fn button_rect(&self, button: WindowChromeButton) -> Option<Rect> {
if !self.buttons_visible || self.fullscreen {
return None;
}
let bar = self.bar_rect();
let diameter = self.traffic_light_diameter();
if diameter == 0 || diameter > bar.height || diameter > bar.width {
return None;
}
let x = bar.x
+ MAC_TRAFFIC_LIGHT_INSET
+ button.index() * (diameter as i32 + MAC_TRAFFIC_LIGHT_GAP);
Some(Rect::new(x, bar.y + ((bar.height - diameter) / 2) as i32, diameter, diameter))
}
pub fn drag_band(&self) -> Rect {
if !self.draggable {
return Rect::new(0, 0, 0, 0);
}
let bar = self.bar_rect();
if self.fullscreen {
return Rect::new(bar.x, bar.y, bar.width, FULLSCREEN_DRAG_BAND.min(bar.height));
}
let trailing_edge = WindowChromeButton::ALL
.iter()
.filter_map(|button| self.button_rect(*button))
.map(|rect| rect.right())
.max()
.unwrap_or(bar.x);
let start =
if self.buttons_visible { trailing_edge + MAC_TRAFFIC_LIGHT_GAP } else { bar.x };
let width = bar.right().saturating_sub(start).max(0) as u32;
Rect::new(start, bar.y, width, bar.height)
}
pub fn is_in_drag_band(&self, pos: Point) -> bool {
let band = self.drag_band();
band.width > 0 && band.contains_point(pos)
}
fn button_at(&self, pos: Point) -> Option<WindowChromeButton> {
WindowChromeButton::ALL
.into_iter()
.find(|button| self.button_rect(*button).is_some_and(|rect| rect.contains_point(pos)))
}
fn request(&self, button: WindowChromeButton) {
match button {
WindowChromeButton::Close => self.close_requested.emit(),
WindowChromeButton::Minimize => self.minimize_requested.emit(),
WindowChromeButton::Zoom => self.zoom_requested.emit(),
}
}
fn button_color(&self, button: WindowChromeButton) -> crate::core::Color {
let colors = palette();
match button {
WindowChromeButton::Close => colors.close,
WindowChromeButton::Minimize => colors.minimize,
WindowChromeButton::Zoom => colors.zoom,
}
}
fn draw_button_symbol(
&self,
context: &mut RenderContext,
button: WindowChromeButton,
rect: Rect,
ink: crate::core::Color,
) {
let center = rect.center();
let reach = (rect.width / 4) as i32;
match button {
WindowChromeButton::Close => {
context.draw_line(
Point::new(center.x - reach, center.y - reach),
Point::new(center.x + reach, center.y + reach),
ink,
);
context.draw_line(
Point::new(center.x + reach, center.y - reach),
Point::new(center.x - reach, center.y + reach),
ink,
);
}
WindowChromeButton::Minimize => {
context.draw_line(
Point::new(center.x - reach, center.y),
Point::new(center.x + reach, center.y),
ink,
);
}
WindowChromeButton::Zoom => {
let h = reach;
let w = (reach * 2 / 3).max(2);
let left = Point::new(center.x - w, center.y - h / 2);
let left_top = Point::new(center.x - w, center.y + h / 2);
let left_apex = Point::new(center.x - 1, center.y);
context.draw_line(left, left_top, ink);
context.draw_line(left_top, left_apex, ink);
context.draw_line(left_apex, left, ink);
let right = Point::new(center.x + w, center.y + h / 2);
let right_bottom = Point::new(center.x + w, center.y - h / 2);
let right_apex = Point::new(center.x + 1, center.y);
context.draw_line(right, right_bottom, ink);
context.draw_line(right_bottom, right_apex, ink);
context.draw_line(right_apex, right, ink);
}
}
}
}
impl Widget for MacWindowChrome {
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) -> Size {
Size::new(320, self.bar_height)
}
fn kind(&self) -> WidgetKind {
WidgetKind::Window
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"close_requested" => {
Some(EventSignalRef::unit("close_requested", &self.close_requested))
}
"minimize_requested" => {
Some(EventSignalRef::unit("minimize_requested", &self.minimize_requested))
}
"zoom_requested" => Some(EventSignalRef::unit("zoom_requested", &self.zoom_requested)),
"fullscreen_requested" => {
Some(EventSignalRef::unit("fullscreen_requested", &self.fullscreen_requested))
}
"window_focus_changed" => Some(EventSignalRef::mapped(
"window_focus_changed",
&self.window_focus_changed,
|value| CapabilityValue::Bool(*value),
)),
_ => None,
}
}
}
impl WidgetProperties for MacWindowChrome {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"title" => Ok(CapabilityValue::String(self.title().to_string())),
"chrome_height" => Ok(CapabilityValue::UInt(self.bar_height() as u64)),
"buttons_visible" => Ok(CapabilityValue::Bool(self.buttons_visible())),
"draggable" => Ok(CapabilityValue::Bool(self.draggable())),
"fullscreen" => Ok(CapabilityValue::Bool(self.is_fullscreen())),
"document_edited" => Ok(CapabilityValue::Bool(self.is_document_edited())),
"focused" => Ok(CapabilityValue::Bool(self.is_focused())),
_ => 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(())
}
"buttons_visible" => {
self.set_buttons_visible(expect_bool(value)?);
Ok(())
}
"draggable" => {
self.set_draggable(expect_bool(value)?);
Ok(())
}
"fullscreen" => {
self.set_fullscreen(expect_bool(value)?);
Ok(())
}
"document_edited" => {
self.set_document_edited(expect_bool(value)?);
Ok(())
}
"focused" => {
self.set_focused(expect_bool(value)?);
Ok(())
}
"chrome_height" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"title",
"chrome_height",
"buttons_visible",
"draggable",
"fullscreen",
"document_edited",
"focused",
BASE_PROPERTY_NAMES
]
}
}
impl Draw for MacWindowChrome {
fn draw(&mut self, context: &mut RenderContext) {
let bar = self.bar_rect();
if bar.width == 0 || bar.height == 0 {
return;
}
let colors = palette();
let style = self.base.style();
let font = role_font(style, 13.0);
let bar_surface = if self.focused { colors.raised } else { colors.inactive_surface };
let chrome_ink = if !self.base.is_enabled() {
colors.muted
} else if self.focused {
colors.ink
} else {
colors.inactive_ink
};
if self.fullscreen {
let strip = Rect::new(bar.x, bar.y, bar.width, FULLSCREEN_DRAG_BAND.min(bar.height));
context.fill_rect(strip, bar_surface.with_alpha(180));
context.draw_line(
Point::new(strip.x, strip.bottom() - 1),
Point::new(strip.right() - 1, strip.bottom() - 1),
colors.separator,
);
draw_focus_ring(context, strip, 0, &self.base, colors.accent);
return;
}
context.fill_rect(bar, bar_surface);
context.draw_line(
Point::new(bar.x, bar.bottom() - 1),
Point::new(bar.right() - 1, bar.bottom() - 1),
colors.separator,
);
let leading = self
.button_rect(WindowChromeButton::Zoom)
.map(|rect| rect.right() + MAC_TRAFFIC_LIGHT_GAP)
.unwrap_or(bar.x + MAC_TRAFFIC_LIGHT_INSET);
let available = bar.right().saturating_sub(leading).max(0) as u32;
if available > 0 && !self.title.is_empty() {
let mut measure = |text: &str| context.measure_text(text, &font).width;
let shown: Cow<'_, str> = elide_to_width(&self.title, available, &mut measure);
let line = context.text_line(bar, &font);
let ink = chrome_ink;
context.draw_text(
Point::new(bar.center().x, line.y),
shown.as_ref(),
&font,
ink,
HorizontalAlignment::Center,
);
}
for button in WindowChromeButton::ALL {
let Some(rect) = self.button_rect(button) else {
continue;
};
let fill = self.button_color(button);
let visible_symbol = self.hovered == Some(button) || self.pressed == Some(button);
context.fill_circle(rect.center(), rect.width / 2, fill);
if visible_symbol {
self.draw_button_symbol(context, button, rect, fill.contrast_color());
}
if self.pressed == Some(button) {
context.draw_circle(rect.center(), rect.width / 2, colors.separator);
}
if button == WindowChromeButton::Close && self.document_edited {
context.fill_circle(rect.center(), (rect.width / 6).max(1), fill.contrast_color());
}
}
draw_focus_ring(context, bar, 0, &self.base, colors.accent);
}
}
impl EventHandler for MacWindowChrome {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MouseMove { pos } => {
let hovered = self.button_at(*pos);
if hovered != self.hovered {
self.hovered = hovered;
self.base.request_redraw();
}
}
Event::MouseLeave { .. } => {
if self.hovered.take().is_some() {
self.base.request_redraw();
}
}
Event::MousePress { pos, button: 1, .. } => {
if let Some(control) = self.button_at(*pos) {
self.pressed = Some(control);
self.base.request_redraw();
}
}
Event::MouseDoubleClick { pos, button: 1, .. } => {
if self.is_in_drag_band(*pos) {
self.zoom_requested.emit();
self.base.request_redraw();
}
}
Event::MouseRelease { pos, button: 1 } => {
if let Some(pressed) = self.pressed.take() {
if self.button_at(*pos) == Some(pressed) {
self.request(pressed);
}
self.base.request_redraw();
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::Recorder;
use super::*;
use crate::core::Color;
use crate::render::RenderCommand;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
fn sample() -> MacWindowChrome {
MacWindowChrome::new(Rect::new(0, 0, 320, 28))
}
#[test]
fn a_fresh_bar_carries_its_title_and_three_controls() {
let bar = sample();
assert_eq!(bar.title(), "Window");
assert_eq!(bar.bar_height(), MAC_TITLE_BAR_HEIGHT);
assert!(bar.buttons_visible());
assert!(bar.draggable());
assert_eq!(bar.size_hint(), Size::new(320, MAC_TITLE_BAR_HEIGHT));
let close = bar.button_rect(WindowChromeButton::Close).expect("close is shown");
let minimize = bar.button_rect(WindowChromeButton::Minimize).expect("minimise is shown");
let zoom = bar.button_rect(WindowChromeButton::Zoom).expect("zoom is shown");
assert_eq!(close.x, MAC_TRAFFIC_LIGHT_INSET);
assert_eq!(minimize.x, close.right() + MAC_TRAFFIC_LIGHT_GAP);
assert_eq!(zoom.x, minimize.right() + MAC_TRAFFIC_LIGHT_GAP);
assert_eq!(close.width, bar.traffic_light_diameter());
}
#[test]
fn the_draggable_band_starts_after_the_last_control() {
let bar = sample();
let zoom = bar.button_rect(WindowChromeButton::Zoom).expect("zoom is shown");
let band = bar.drag_band();
assert_eq!(band.x, zoom.right() + MAC_TRAFFIC_LIGHT_GAP);
assert_eq!(band.right(), bar.bar_rect().right());
assert!(bar.is_in_drag_band(Point::new(250, 14)));
assert!(!bar.is_in_drag_band(Point::new(5, 14)), "a control is not a drag handle");
}
#[test]
fn turning_dragging_off_empties_the_band() {
let mut bar = sample();
bar.set_draggable(false);
assert_eq!(bar.drag_band(), Rect::new(0, 0, 0, 0));
assert!(!bar.is_in_drag_band(Point::new(250, 14)));
}
#[test]
fn hiding_the_controls_extends_the_band_and_removes_the_buttons() {
let mut bar = sample();
bar.set_buttons_visible(false);
assert!(bar.button_rect(WindowChromeButton::Close).is_none());
assert_eq!(bar.drag_band().x, bar.bar_rect().x);
}
#[test]
fn each_control_emits_its_own_request() {
let mut bar = sample();
let closes = Arc::new(AtomicUsize::new(0));
let minimizes = Arc::new(AtomicUsize::new(0));
let zooms = Arc::new(AtomicUsize::new(0));
bar.close_requested.connect({
let counter = Arc::clone(&closes);
move || {
counter.fetch_add(1, Ordering::SeqCst);
}
});
bar.minimize_requested.connect({
let counter = Arc::clone(&minimizes);
move || {
counter.fetch_add(1, Ordering::SeqCst);
}
});
bar.zoom_requested.connect({
let counter = Arc::clone(&zooms);
move || {
counter.fetch_add(1, Ordering::SeqCst);
}
});
for control in WindowChromeButton::ALL {
let rect = bar.button_rect(control).expect("the control is shown");
bar.handle_event(&Event::MousePress { pos: rect.center(), button: 1, modifiers: 0 });
bar.handle_event(&Event::MouseRelease { pos: rect.center(), button: 1 });
}
assert_eq!(closes.load(Ordering::SeqCst), 1);
assert_eq!(minimizes.load(Ordering::SeqCst), 1);
assert_eq!(zooms.load(Ordering::SeqCst), 1);
}
#[test]
fn pressing_the_draggable_band_requests_nothing() {
let mut bar = sample();
let closes = Arc::new(AtomicUsize::new(0));
bar.close_requested.connect({
let counter = Arc::clone(&closes);
move || {
counter.fetch_add(1, Ordering::SeqCst);
}
});
bar.handle_event(&Event::MousePress { pos: Point::new(250, 14), button: 1, modifiers: 0 });
assert_eq!(closes.load(Ordering::SeqCst), 0);
}
#[test]
fn the_property_route_reads_and_writes_every_published_name() {
let mut bar = sample();
assert_eq!(bar.get("title"), Ok(CapabilityValue::String("Window".to_string())));
bar.set("title", CapabilityValue::String("Notes".to_string())).unwrap();
assert_eq!(bar.get("title"), Ok(CapabilityValue::String("Notes".to_string())));
assert_eq!(
bar.get("chrome_height"),
Ok(CapabilityValue::UInt(MAC_TITLE_BAR_HEIGHT as u64))
);
bar.set("buttons_visible", CapabilityValue::Bool(false)).unwrap();
assert_eq!(bar.get("buttons_visible"), Ok(CapabilityValue::Bool(false)));
bar.set("draggable", CapabilityValue::Bool(false)).unwrap();
assert_eq!(bar.get("draggable"), Ok(CapabilityValue::Bool(false)));
assert_eq!(
bar.set("chrome_height", CapabilityValue::UInt(40)),
Err(CapabilityAccessError::ReadOnlyProperty)
);
}
#[test]
fn the_bar_paints_its_surface_title_and_three_controls() {
let mut bar = sample();
let mut recorder = Recorder::new(320, 28);
recorder.begin_frame(Color::WHITE);
{
let mut context = RenderContext::new(&mut recorder);
bar.draw(&mut context);
}
recorder.end_frame();
let titles: Vec<&str> = recorder
.commands
.iter()
.filter_map(|command| match command {
RenderCommand::DrawText { text, .. } => Some(text.as_ref()),
_ => None,
})
.collect();
assert_eq!(titles, vec!["Window"]);
let circles = recorder
.commands
.iter()
.filter(|command| matches!(command, RenderCommand::FillCircle { .. }))
.count();
assert_eq!(circles, 3, "all three traffic lights are painted");
}
#[test]
fn every_painted_control_stays_inside_the_bar() {
let mut bar = sample();
let mut recorder = Recorder::new(320, 28);
recorder.begin_frame(Color::WHITE);
{
let mut context = RenderContext::new(&mut recorder);
bar.draw(&mut context);
}
recorder.end_frame();
for command in &recorder.commands {
if let RenderCommand::FillCircle { center, radius, .. } = command {
assert!(center.x - *radius as i32 >= 0);
assert!(center.y - *radius as i32 >= 0);
assert!(center.y + *radius as i32 <= 28);
}
}
}
#[test]
fn fullscreen_focus_and_edited_state_are_reported() {
let mut bar = sample();
let fullscreen = Arc::new(AtomicUsize::new(0));
let f = Arc::clone(&fullscreen);
bar.fullscreen_requested.connect(move || {
f.fetch_add(1, Ordering::SeqCst);
});
bar.request_fullscreen();
assert_eq!(fullscreen.load(Ordering::SeqCst), 1);
assert!(!bar.is_fullscreen());
bar.set_fullscreen(true);
assert!(bar.is_fullscreen());
let focused = Arc::new(std::sync::Mutex::new(None));
let observed = Arc::clone(&focused);
bar.window_focus_changed.connect(move |value: Arc<bool>| {
*observed.lock().unwrap() = Some(*value);
});
bar.set_focused(false);
assert_eq!(*focused.lock().unwrap(), Some(false));
assert!(!bar.is_focused());
bar.set_document_edited(true);
assert!(bar.is_document_edited());
let mut fullscreen_recorder = Recorder::new(320, 28);
fullscreen_recorder.begin_frame(Color::WHITE);
{
let mut context = RenderContext::new(&mut fullscreen_recorder);
bar.draw(&mut context);
}
fullscreen_recorder.end_frame();
let fullscreen_circles = fullscreen_recorder
.commands
.iter()
.filter(|command| matches!(command, RenderCommand::FillCircle { .. }))
.count();
assert_eq!(fullscreen_circles, 0, "fullscreen hides every status control");
bar.set_fullscreen(false);
let mut recorder = Recorder::new(320, 28);
recorder.begin_frame(Color::WHITE);
{
let mut context = RenderContext::new(&mut recorder);
bar.draw(&mut context);
}
recorder.end_frame();
let circles = recorder
.commands
.iter()
.filter(|command| matches!(command, RenderCommand::FillCircle { .. }))
.count();
assert_eq!(circles, 4, "three traffic lights plus the edited dot");
}
#[test]
fn double_clicking_the_drag_band_requests_zoom() {
let mut bar = sample();
let zooms = Arc::new(AtomicUsize::new(0));
let z = Arc::clone(&zooms);
bar.zoom_requested.connect(move || {
z.fetch_add(1, Ordering::SeqCst);
});
bar.handle_event(&Event::MouseDoubleClick { pos: Point::new(250, 14), button: 1 });
assert_eq!(zooms.load(Ordering::SeqCst), 1);
}
}