use crate::ui_components::blended_colors; use crate::{appearance::Appearance, themes::theme::Fill, ui_components::icons}; use pathfinder_geometry::vector::vec2f; use warpui::{ color::ColorU, elements::{ Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Element, Flex, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack, Text, }, fonts::{Properties, Weight}, keymap::FixedBinding, ui_components::components::{Coords, UiComponent, UiComponentStyles}, AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; pub const MODAL_CORNER_RADIUS: Radius = Radius::Pixels(8.); pub const MODAL_WIDTH: f32 = 440.; pub const MODAL_HEADER_HEIGHT: f32 = 70.; pub const MODAL_PADDING: f32 = 28.; #[derive(Clone)] pub struct Modal { title: Option, header_icon: Option, header_icon_color: Option, body: ViewHandle, show_close_modal_button: bool, dismiss_on_click: bool, modal_styles: UiComponentStyles, header_styles: UiComponentStyles, body_styles: UiComponentStyles, close_modal_hover_state: MouseStateHandle, background_opacity: u8, offset_positioning: OffsetPositioning, } #[derive(Clone, Debug, Default)] pub enum ModalState { Open, #[default] Closed, } /// Helper struct that holds the view handle and the state of the "handled" modal. /// It's supposed to be used within places like Input or Workspace, where there are multiple /// internal views for which we want the owner to decide whether it's open or closed (instead of /// multiplying the amount of members by adding extra booleans to hold that state). pub struct ModalViewState { pub view: ViewHandle, state: ModalState, } impl ModalViewState { pub fn new(view: ViewHandle) -> Self { Self { view, state: Default::default(), } } pub fn is_open(&self) -> bool { matches!(self.state, ModalState::Open) } pub fn open(&mut self) { self.state = ModalState::Open; } pub fn close(&mut self) { self.state = ModalState::Closed; } pub fn render(&self) -> Box { ChildView::new(&self.view).finish() } } impl Clone for ModalViewState { fn clone(&self) -> Self { Self { view: self.view.clone(), state: self.state.clone(), } } } #[derive(Debug)] pub enum ModalAction { Close, } pub fn init(app: &mut AppContext) { use warpui::keymap::macros::*; app.register_fixed_bindings(vec![FixedBinding::new( "escape", ModalAction::Close, id!("Modal"), )]); } #[derive(PartialEq, Eq)] pub enum ModalEvent { Close, } /// A generic modal view that renders any view as its body. // TODO(alokedesai): Consolidate all the various modals in our app to use this view. impl Modal { pub fn new(title: Option, body: ViewHandle, ctx: &mut ViewContext) -> Self { let appearance_handle = Appearance::handle(ctx); ctx.observe(&appearance_handle, Self::handle_appearance_update); let appearance = appearance_handle.as_ref(ctx); let theme = appearance.theme(); Self { title, header_icon: None, header_icon_color: None, body, show_close_modal_button: true, dismiss_on_click: false, modal_styles: UiComponentStyles { border_radius: Some(CornerRadius::with_all(MODAL_CORNER_RADIUS)), border_width: Some(1.), border_color: Some(theme.outline().into()), width: Some(MODAL_WIDTH), height: Some(480.), ..Default::default() }, header_styles: UiComponentStyles { font_family_id: Some(appearance.header_font_family()), font_color: Some(theme.active_ui_text_color().into()), font_size: Some(appearance.header_font_size()), font_weight: Some(Weight::Normal), height: Some(MODAL_HEADER_HEIGHT), padding: Some(Coords { top: 20., bottom: 15., left: MODAL_PADDING, right: MODAL_PADDING, }), ..Default::default() }, body_styles: UiComponentStyles { background: Some(theme.surface_2().into()), padding: Some(Coords { top: MODAL_PADDING, bottom: MODAL_PADDING, left: MODAL_PADDING, right: MODAL_PADDING, }), height: Some(410.), ..Default::default() }, close_modal_hover_state: Default::default(), background_opacity: 179, offset_positioning: Self::default_offset_positioning(), } } pub fn close_modal_button_disabled(mut self) -> Self { self.show_close_modal_button = false; self } /// Turn on "Dismiss on click" behavior /// outside of the Modal pub fn with_dismiss_on_click(mut self) -> Self { self.dismiss_on_click = true; self } /// Overwrites _some_ styles passed in `modal_style` parameter pub fn with_modal_style(mut self, styles: UiComponentStyles) -> Self { self.modal_styles = self.modal_styles.merge(styles); self } /// Overwrites _some_ styles passed in `header_style` parameter pub fn with_header_style(mut self, styles: UiComponentStyles) -> Self { self.header_styles = self.header_styles.merge(styles); self } /// Overwrites _some_ styles passed in `body_style` parameter pub fn with_body_style(mut self, styles: UiComponentStyles) -> Self { self.body_styles = self.body_styles.merge(styles); self } pub fn with_background_opacity(mut self, opacity: u8) -> Self { self.background_opacity = opacity; self } pub fn set_title(&mut self, title: Option) { self.title = title; } pub fn set_header_icon(&mut self, icon: Option) { self.header_icon = icon; } pub fn set_header_icon_color(&mut self, color: Option) { self.header_icon_color = color; } pub fn set_offset_positioning(&mut self, offset_positioning: OffsetPositioning) { self.offset_positioning = offset_positioning; } fn handle_appearance_update( &mut self, handle: ModelHandle, ctx: &mut ViewContext, ) { let appearance = handle.as_ref(ctx); let theme = appearance.theme(); // Update theme dependent styles self.modal_styles = self.modal_styles.merge(UiComponentStyles { border_color: Some(theme.outline().into()), ..Default::default() }); self.header_styles = self.header_styles.merge(UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_color: Some(theme.active_ui_text_color().into()), ..Default::default() }); self.body_styles = self.body_styles.merge(UiComponentStyles { background: Some(theme.surface_2().into()), ..Default::default() }); } fn render_close_modal_button(&self, appearance: &Appearance) -> Box { const BUTTON_DIAMETER: f32 = 24.; appearance .ui_builder() .close_button(BUTTON_DIAMETER, self.close_modal_hover_state.clone()) .build() .on_click(|ctx, _, _| ctx.dispatch_typed_action(ModalAction::Close)) .finish() } fn render_header_icon(&self, appearance: &Appearance) -> Option> { if let Some(icon) = self.header_icon { let icon_size = self .header_styles .font_size .unwrap_or(appearance.header_font_size()); // first check if there's an icon color override, otherwise fall back to the // header font color, otherwise fall back to the theme's active_ui_text_color let icon_color = self.header_icon_color.unwrap_or( self.header_styles .font_color .unwrap_or(appearance.theme().active_ui_text_color().into()) .into(), ); Some( Align::new( Container::new( ConstrainedBox::new(icon.to_warpui_icon(icon_color).finish()) .with_width(icon_size) .with_height(icon_size) .finish(), ) .with_margin_right(icon_size / 2.) .finish(), ) .finish(), ) } else { None } } fn render_header(&self, appearance: &Appearance) -> Option> { if let Some(title) = &self.title { let mut header = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); if let Some(icon) = self.render_header_icon(appearance) { header.add_child(icon); } header.add_child( Shrinkable::new( 1.0, Align::new( Text::new_inline( title.clone(), self.header_styles .font_family_id .unwrap_or(appearance.ui_font_family()), self.header_styles .font_size .unwrap_or(appearance.header_font_size()), ) .with_color( self.header_styles .font_color .unwrap_or(appearance.theme().active_ui_text_color().into()), ) .with_style( Properties::default() .weight(self.header_styles.font_weight.unwrap_or(Weight::Normal)), ) .finish(), ) .left() .finish(), ) .finish(), ); if self.show_close_modal_button { header.add_child(self.render_close_modal_button(appearance)) } let mut container = Container::new(header.finish()); if let Some(background) = self.header_styles.background { container = container.with_background(background); } if let Some(border_radius) = self.header_styles.border_radius { container = container.with_corner_radius(border_radius.top()); } if let Some(padding) = self.header_styles.padding { container = container .with_padding_left(padding.left) .with_padding_top(padding.top) .with_padding_right(padding.right) .with_padding_bottom(padding.bottom); } Some( ConstrainedBox::new(container.finish()) .with_max_height(self.header_styles.height.unwrap_or(MODAL_HEADER_HEIGHT)) .finish(), ) } else { None } } fn render_body(&self) -> Box { let mut container = Container::new(ChildView::new(&self.body).finish()); if self.title.is_some() { container = container .with_corner_radius(self.modal_styles.border_radius.unwrap_or_default().bottom()); } else { // If there is no title, then the body has to have its top rounded container = container.with_corner_radius(self.modal_styles.border_radius.unwrap_or_default()); }; if let Some(padding) = self.body_styles.padding { container = container .with_padding_left(padding.left) .with_padding_top(padding.top) .with_padding_right(padding.right) .with_padding_bottom(padding.bottom); } ConstrainedBox::new(container.finish()) .with_max_height(self.body_styles.height.unwrap()) .finish() } pub fn body(&self) -> &ViewHandle { &self.body } fn default_offset_positioning() -> OffsetPositioning { OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::Center, ChildAnchor::Center, ) } } impl Entity for Modal { type Event = ModalEvent; } impl TypedActionView for Modal { type Action = ModalAction; fn handle_action(&mut self, action: &ModalAction, ctx: &mut ViewContext) { match action { ModalAction::Close => { ctx.emit(ModalEvent::Close); } } } } impl View for Modal { fn ui_name() -> &'static str { "Modal" } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { ctx.focus(&self.body); ctx.notify(); } } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let header = self.render_header(appearance); let contents = if let Some(header) = header { Flex::column() .with_child(header) .with_child(self.render_body()) .finish() } else { self.render_body() }; let mut modal = ConstrainedBox::new( Container::new(contents) .with_background(blended_colors::neutral_2(appearance.theme())) .with_corner_radius(self.modal_styles.border_radius.unwrap_or_default()) .with_border( Border::all(self.modal_styles.border_width.unwrap()) .with_border_fill(self.modal_styles.border_color.unwrap()), ) .with_margin_top(35.) .finish(), ) .with_max_width(self.modal_styles.width.unwrap()) .with_max_height(self.modal_styles.height.unwrap()) .finish(); if self.dismiss_on_click { modal = Dismiss::new(modal) .on_dismiss(|ctx, _app| ctx.dispatch_typed_action(ModalAction::Close)) .finish(); } // Stack needed so that modal can get bounds information, // specifically to ensure no overlap with the window's traffic lights let mut stack = Stack::new(); stack.add_positioned_child(modal, self.offset_positioning.clone()); Container::new(Align::new(stack.finish()).finish()) .with_background_color(ColorU::new(0, 0, 0, self.background_opacity)) .with_corner_radius(app.windows().window_corner_radius()) .finish() } }