use std::{marker::PhantomData, rc::Rc}; use markdown_parser::{ FormattedText, FormattedTextFragment, FormattedTextInline, FormattedTextLine, }; use warpui::elements::HyperlinkLens; use warpui::{ elements::{ ConstrainedBox, Container, CrossAxisAlignment, Flex, FormattedTextElement, HighlightedHyperlink, HyperlinkUrl, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Shrinkable, }, fonts::Weight, ui_components::{ button::ButtonVariant, components::{UiComponent, UiComponentStyles}, }, Action, AppContext, Element, Entity, EventContext, SingletonEntity, TypedActionView, View, ViewContext, }; use crate::{appearance::Appearance, ui_components::icons::Icon}; use pathfinder_geometry::vector::Vector2F; const CLOSE_BUTTON_DIAMETER: f32 = 20.; const INNER_MARGIN: f32 = 12.; const HORIZ_PADDING: f32 = 16.; const VERT_PADDING: f32 = 8.; #[derive(Clone, Copy, Debug)] pub enum DismissalType { /// The banner may be re-shown to the user multiple times in new or existing sessions. Temporary, /// The banner should not be shown again to the user, whether in a new or existing session. /// Dismissal state should also persist across app sessions (e.g. when Warp is restarted). Permanent, } pub enum BannerEvent { Dismiss(DismissalType), Action(T), } pub struct BannerTextContent { /// The text (with optional formatting and links) to be displayed as the /// primary banner content. text: FormattedTextInline, /// Information about which link in the text is under the cursor, if any. highlighted_link: HighlightedHyperlink, phantom_data: PhantomData, } impl BannerTextContent { #[allow(dead_code)] pub fn plain_text(text: impl Into) -> Self { Self { text: vec![FormattedTextFragment::plain_text(text)], highlighted_link: Default::default(), phantom_data: PhantomData, } } pub fn formatted_text(text: FormattedTextInline) -> Self { Self { text, highlighted_link: Default::default(), phantom_data: PhantomData, } } fn render(&self, appearance: &Appearance) -> Box { let font_size = font_size(appearance); let font_family = appearance.ui_font_family(); let code_font_family = appearance.monospace_font_family(); let font_color = appearance.theme().active_ui_text_color().into(); FormattedTextElement::new( FormattedText::new([FormattedTextLine::Line(self.text.clone())]), font_size, font_family, code_font_family, font_color, self.highlighted_link.clone(), ) .register_default_click_handlers_with_action_support(|hyperlink_lens, evt, _ctx| { match hyperlink_lens { HyperlinkLens::Url(url) => { evt.dispatch_typed_action(BannerAction::::HyperlinkClick(HyperlinkUrl { url: url.to_owned(), })); } HyperlinkLens::Action(action_ref) => { if let Some(action) = action_ref.as_any().downcast_ref::() { evt.dispatch_typed_action(action.clone()); } } } }) .finish() } } type BannerClickCallback = dyn Fn(&mut EventContext, &AppContext, Vector2F) + 'static; pub struct BannerTextButton { text: String, mouse_state_handle: MouseStateHandle, on_click: Rc, } impl BannerTextButton { pub fn new(text: String, on_click: Rc) -> Self { Self { text, mouse_state_handle: Default::default(), on_click, } } } /// Informational banner for the terminal. pub struct Banner { /// Optional icon to render at the start of the banner, /// before the text content. icon: Option, text_content: BannerTextContent, /// Optional buttons to render at the end, /// after the text content and before the close button. end_buttons: Vec, /// Optional close button (X) rendered at the very end of the banner. /// Clicking the close button temporarily dismisses the banner. close_button_hover_state: Option, } #[derive(Clone, Debug)] pub enum BannerAction { Dismiss(DismissalType), HyperlinkClick(HyperlinkUrl), Action(T), } impl Banner { /// Creates a plain banner with a close button. pub fn new(content: BannerTextContent) -> Self { Self::new_internal(content, vec![], /* with_close_button */ true) } /// Creates a plain banner without a close button. pub fn new_without_close(content: BannerTextContent) -> Self { Self::new_internal(content, vec![], /* with_close_button */ false) } /// Creates a banner with the given text buttons and a close button. pub fn new_with_buttons( content: BannerTextContent, buttons: Vec, with_close_button: bool, ) -> Self { Self::new_internal(content, buttons, with_close_button) } /// Creates a banner with a single "Don't show me again" button /// that will permanently dismiss the banner when clicked, as well /// as a close button that will temporarily dismiss it when clicked. pub fn new_permanently_dismissible(content: BannerTextContent) -> Self { Self::new_with_buttons( content, vec![Self::permanent_dismissal_button()], /* with_close_button */ true, ) } fn permanent_dismissal_button() -> BannerTextButton { BannerTextButton::new( String::from("Don't show me again"), Rc::new(|ctx, _, _| { ctx.dispatch_typed_action(BannerAction::::Dismiss(DismissalType::Permanent)); }), ) } fn new_internal( text_content: BannerTextContent, end_buttons: Vec, with_close_button: bool, ) -> Self { Self { text_content, close_button_hover_state: if with_close_button { Some(Default::default()) } else { None }, end_buttons, icon: None, } } /// Replaces the banner's content with new items. pub fn set_content(&mut self, content: BannerTextContent, ctx: &mut ViewContext) { self.text_content = content; ctx.notify(); } pub fn with_icon(mut self, icon: Icon) -> Self { self.icon = Some(icon); self } fn render_icon(appearance: &Appearance, icon: &Icon) -> Box { let icon_size = font_size(appearance); ConstrainedBox::new(icon.to_warpui_icon(appearance.theme().accent()).finish()) .with_width(icon_size) .with_height(icon_size) .finish() } fn render_close_button( &self, appearance: &Appearance, hover_state: &MouseStateHandle, ) -> Box { appearance .ui_builder() .close_button(CLOSE_BUTTON_DIAMETER, hover_state.clone()) .with_style(UiComponentStyles { font_color: Some(appearance.theme().active_ui_text_color().into()), ..Default::default() }) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(BannerAction::::Dismiss(DismissalType::Temporary)); }) .finish() } fn render_text_button( appearance: &Appearance, button_state: &BannerTextButton, ) -> Box { let font_size = font_size(appearance); let on_click_fn = button_state.on_click.clone(); appearance .ui_builder() .button(ButtonVariant::Text, button_state.mouse_state_handle.clone()) .with_text_label(button_state.text.clone()) .with_style(UiComponentStyles { font_size: Some(font_size), font_weight: Some(Weight::Semibold), ..Default::default() }) .build() .on_click(move |event_ctx, app_ctx, v2f| on_click_fn(event_ctx, app_ctx, v2f)) .finish() } } impl Entity for Banner { type Event = BannerEvent; } impl TypedActionView for Banner { type Action = BannerAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { BannerAction::Dismiss(dismissal_type) => { ctx.emit(BannerEvent::Dismiss(*dismissal_type)); } BannerAction::HyperlinkClick(hyperlink) => { ctx.notify(); ctx.open_url(&hyperlink.url); } BannerAction::Action(action) => { ctx.emit(BannerEvent::Action(action.clone())); } } } } impl View for Banner { fn ui_name() -> &'static str { "Banner" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let mut left_side = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_constrain_horizontal_bounds_to_parent(true); if let Some(icon) = &self.icon { left_side.add_child( Container::new(Banner::::render_icon(appearance, icon)) .with_margin_right(INNER_MARGIN) .finish(), ); } left_side.add_child(Shrinkable::new(1., self.text_content.render(appearance)).finish()); let mut right_side_banner_actions = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::End); for text_button in &self.end_buttons { right_side_banner_actions.add_child( Container::new(Banner::::render_text_button(appearance, text_button)) .with_margin_left(INNER_MARGIN) .finish(), ); } if let Some(hover_state) = &self.close_button_hover_state { right_side_banner_actions.add_child( Container::new(self.render_close_button(appearance, hover_state)) .with_margin_left(INNER_MARGIN) .finish(), ); } let banner = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_children([ Shrinkable::new(1., left_side.finish()).finish(), right_side_banner_actions.finish(), ]); Container::new(banner.finish()) .with_padding_left(HORIZ_PADDING) .with_padding_right(HORIZ_PADDING) .with_padding_top(VERT_PADDING) .with_padding_bottom(VERT_PADDING) .with_background(appearance.theme().surface_2()) .finish() } } fn font_size(appearance: &Appearance) -> f32 { appearance.ui_font_size() }