458 lines
15 KiB
Rust
458 lines
15 KiB
Rust
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<T> {
|
|
title: Option<String>,
|
|
header_icon: Option<icons::Icon>,
|
|
header_icon_color: Option<Fill>,
|
|
body: ViewHandle<T>,
|
|
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<T> {
|
|
pub view: ViewHandle<T>,
|
|
state: ModalState,
|
|
}
|
|
|
|
impl<T: View> ModalViewState<T> {
|
|
pub fn new(view: ViewHandle<T>) -> 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<dyn Element> {
|
|
ChildView::new(&self.view).finish()
|
|
}
|
|
}
|
|
|
|
impl<T> Clone for ModalViewState<T> {
|
|
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<T: View> Modal<T> {
|
|
pub fn new(title: Option<String>, body: ViewHandle<T>, ctx: &mut ViewContext<Self>) -> 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<String>) {
|
|
self.title = title;
|
|
}
|
|
|
|
pub fn set_header_icon(&mut self, icon: Option<icons::Icon>) {
|
|
self.header_icon = icon;
|
|
}
|
|
|
|
pub fn set_header_icon_color(&mut self, color: Option<Fill>) {
|
|
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<Appearance>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
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<dyn Element> {
|
|
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<Box<dyn Element>> {
|
|
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<Box<dyn Element>> {
|
|
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<dyn Element> {
|
|
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<T> {
|
|
&self.body
|
|
}
|
|
|
|
fn default_offset_positioning() -> OffsetPositioning {
|
|
OffsetPositioning::offset_from_parent(
|
|
vec2f(0., 0.),
|
|
ParentOffsetBounds::WindowByPosition,
|
|
ParentAnchor::Center,
|
|
ChildAnchor::Center,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl<T: View> Entity for Modal<T> {
|
|
type Event = ModalEvent;
|
|
}
|
|
|
|
impl<T: View> TypedActionView for Modal<T> {
|
|
type Action = ModalAction;
|
|
|
|
fn handle_action(&mut self, action: &ModalAction, ctx: &mut ViewContext<Self>) {
|
|
match action {
|
|
ModalAction::Close => {
|
|
ctx.emit(ModalEvent::Close);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: View> View for Modal<T> {
|
|
fn ui_name() -> &'static str {
|
|
"Modal"
|
|
}
|
|
|
|
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
|
if focus_ctx.is_self_focused() {
|
|
ctx.focus(&self.body);
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
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()
|
|
}
|
|
}
|