Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+503
View File
@@ -0,0 +1,503 @@
use std::time::Duration;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use uuid::Uuid;
use warpui::elements::{DropShadow, Expanded};
use warpui::r#async::Timer;
use warpui::WindowId;
use warpui::{
elements::{
ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DispatchEventResult, EventHandler, Flex, Hoverable, Icon, MouseStateHandle,
OffsetPositioning, Padding, ParentElement, PositionedElementAnchor,
PositionedElementOffsetBounds, Radius, SavePosition, Stack,
},
keymap::Keystroke,
r#async::SpawnedFutureHandle,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, Entity, EntityId, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::appearance::Appearance;
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
use crate::terminal::view::TerminalAction;
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::workspace::{Workspace, WorkspaceAction};
const AGENT_TOAST_WIDTH: f32 = 260.;
const AGENT_TOAST_PADDING: f32 = 12.;
const AGENT_TOAST_CORNER_RADIUS: f32 = 4.;
const CLOSE_BUTTON_SIZE: f32 = 20.;
/// Data for an individual agent toast
struct AgentToastData {
/// The toast itself
toast: AgentToast,
/// Abort handle for timeout-based dismissal
abort_handle: Option<SpawnedFutureHandle>,
/// Unique identifier for the toast
uuid: Uuid,
}
/// A stack of agent-specific toasts for displaying task completion notifications
pub struct AgentToastStack {
timeout: Duration,
toasts: Vec<AgentToastData>,
/// Cached keystroke for the jump to latest toast action
jump_to_toast_shortcut: Option<Keystroke>,
/// Navigation data for the most recent toast. Persists even after toast is dismissed
latest_toast_navigation_data: Option<(WindowId, usize, EntityId)>,
}
impl AgentToastStack {
pub fn new(timeout: Duration, ctx: &mut ViewContext<Self>) -> Self {
// Set up caching for the keyboard shortcut
let jump_to_toast_shortcut =
keybinding_name_to_keystroke("workspace:jump_to_latest_toast", ctx);
// Subscribe to keybinding changes to update the cached shortcut
ctx.subscribe_to_model(
&KeybindingChangedNotifier::handle(ctx),
move |me, _, event, ctx| {
let KeybindingChangedEvent::BindingChanged {
binding_name,
new_trigger,
} = event;
if binding_name == "workspace:jump_to_latest_toast" {
me.jump_to_toast_shortcut = new_trigger.clone();
ctx.notify();
}
},
);
Self {
timeout,
toasts: Vec::new(),
jump_to_toast_shortcut,
latest_toast_navigation_data: None,
}
}
/// Add a new agent toast to the stack
pub fn add_toast(&mut self, toast: AgentToast, ctx: &mut ViewContext<Self>) {
let uuid = Uuid::new_v4();
let abort_handle = ctx.spawn_abortable(
Timer::after(self.timeout),
move |view, _, ctx| view.dismiss_toast_by_uuid(&uuid, ctx),
|_, _| {},
);
self.latest_toast_navigation_data =
Some((toast.window_id, toast.tab_index, toast.terminal_view_id));
self.toasts.push(AgentToastData {
toast,
abort_handle: Some(abort_handle),
uuid,
});
ctx.notify();
}
/// Dismiss a toast by its UUID
pub fn dismiss_toast_by_uuid(&mut self, uuid: &Uuid, ctx: &mut ViewContext<Self>) {
if let Some(index) = self.toasts.iter().position(|toast| toast.uuid == *uuid) {
let toast_data = self.toasts.remove(index);
if let Some(abort_handle) = toast_data.abort_handle {
abort_handle.abort();
}
ctx.notify();
}
}
/// Cancel the dismissal timeout for a toast
pub fn cancel_dismissal_timeout(&mut self, uuid: &Uuid) {
if let Some(toast_data) = self.toasts.iter_mut().find(|toast| toast.uuid == *uuid) {
if let Some(abort_handle) = toast_data.abort_handle.take() {
abort_handle.abort();
}
}
}
/// Start a new dismissal timeout for a toast
pub fn start_dismissal_timeout(&mut self, uuid: Uuid, ctx: &mut ViewContext<Self>) {
if let Some(toast_data) = self.toasts.iter_mut().find(|toast| toast.uuid == uuid) {
// Cancel any existing timeout
if let Some(abort_handle) = toast_data.abort_handle.take() {
abort_handle.abort();
}
// Start a new timeout
let abort_handle = ctx.spawn_abortable(
Timer::after(self.timeout),
move |view, _, ctx| view.dismiss_toast_by_uuid(&uuid, ctx),
|_, _| {},
);
toast_data.abort_handle = Some(abort_handle);
}
}
/// Get the UUID of the most recent (latest) toast
pub fn latest_toast_uuid(&self) -> Option<Uuid> {
self.toasts.last().map(|toast_data| toast_data.uuid)
}
pub fn get_latest_toast_navigation_data(&self) -> Option<(WindowId, usize, EntityId)> {
self.latest_toast_navigation_data
}
}
impl View for AgentToastStack {
fn ui_name() -> &'static str {
"AgentToastStack"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let mut rendered_toasts =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Center);
let latest_toast_uuid = self.toasts.last().map(|toast| toast.uuid);
// Render toasts in reverse order so most recent appears at top
for toast_data in self.toasts.iter().rev() {
let is_latest = latest_toast_uuid == Some(toast_data.uuid);
rendered_toasts.add_child(
Container::new(toast_data.toast.render(
app,
toast_data.uuid,
is_latest,
self.jump_to_toast_shortcut.clone(),
))
.with_margin_bottom(5.)
.finish(),
);
}
Container::new(rendered_toasts.finish())
// Tried handling this with OffsetPositioning as we do with top margin.
// For whatever reason, it did not work for right margin when using TopRight alignment.
.with_margin_right(AGENT_TOAST_PADDING)
.finish()
}
}
impl Entity for AgentToastStack {
type Event = ();
}
/// Actions that can be dispatched on the agent toast stack
#[derive(Debug)]
pub enum AgentToastAction {
ClickDismissButton(Uuid),
CancelDismissalTimeout(Uuid),
StartDismissalTimeout(Uuid),
ClickToastBody(Uuid),
}
impl TypedActionView for AgentToastStack {
type Action = AgentToastAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AgentToastAction::ClickDismissButton(uuid) => {
self.dismiss_toast_by_uuid(uuid, ctx);
}
AgentToastAction::CancelDismissalTimeout(uuid) => {
self.cancel_dismissal_timeout(uuid);
}
AgentToastAction::StartDismissalTimeout(uuid) => {
self.start_dismissal_timeout(*uuid, ctx);
}
AgentToastAction::ClickToastBody(uuid) => {
if let Some((window_id, tab_id, terminal_view_id)) = self
.toasts
.iter()
.find(|toast| toast.uuid == *uuid)
.map(|toast| {
(
toast.toast.window_id,
toast.toast.tab_index,
toast.toast.terminal_view_id,
)
})
{
ctx.windows().show_window_and_focus_app(window_id);
if let Some(workspaces) = ctx.views_of_type::<Workspace>(window_id) {
if let Some(handle) = workspaces.first() {
ctx.dispatch_typed_action_for_view(
window_id,
handle.id(),
&WorkspaceAction::ActivateTab(tab_id),
);
}
}
ctx.dispatch_typed_action_for_view(
window_id,
terminal_view_id,
&TerminalAction::Focus,
);
}
self.dismiss_toast_by_uuid(uuid, ctx);
}
}
}
}
/// A specialized toast for Agent Mode completion notifications
#[derive(Clone)]
pub struct AgentToast {
task_name: String,
icon: Icon,
window_id: WindowId,
tab_index: usize,
terminal_view_id: EntityId,
close_button_mouse_state: MouseStateHandle,
container_hover_state: MouseStateHandle,
close_button_hover_state: MouseStateHandle,
}
impl AgentToast {
pub fn new(
task_name: String,
icon: Icon,
window_id: WindowId,
tab_index: usize,
terminal_view_id: EntityId,
) -> Self {
Self {
task_name,
icon,
window_id,
tab_index,
terminal_view_id,
close_button_mouse_state: Default::default(),
container_hover_state: Default::default(),
close_button_hover_state: Default::default(),
}
}
fn text_color(&self, appearance: &Appearance) -> ColorU {
appearance
.theme()
.main_text_color(appearance.theme().background())
.into()
}
fn position_id(&self, uuid: Uuid) -> String {
format!("agent_toast_{uuid}")
}
pub fn render(
&self,
app: &AppContext,
uuid: Uuid,
is_latest: bool,
jump_to_toast_keystroke: Option<Keystroke>,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder();
let mut row = Flex::row();
let icon_size = appearance.ui_font_size() * 1.2;
row.add_child(
Container::new(
ConstrainedBox::new(self.icon.finish())
.with_height(icon_size)
.with_width(icon_size)
.finish(),
)
.with_margin_right(8.)
// Accounts for line height
.with_vertical_margin(1.)
.finish(),
);
row.add_child(
Expanded::new(
1.,
Flex::column()
.with_child({
let font_size = appearance.ui_font_size() * 1.2;
let line_height = font_size * appearance.line_height_ratio();
let max_height_for_3_lines = line_height * 3.0;
let text_content = ConstrainedBox::new(
ui_builder
.wrappable_text(self.task_name.clone(), true)
.with_style(UiComponentStyles {
font_size: Some(font_size),
font_color: Some(self.text_color(appearance)),
..Default::default()
})
.build()
.finish(),
)
.with_max_height(max_height_for_3_lines)
.finish();
if is_latest {
// Add keyboard shortcut to the latest toast
let mut row =
Flex::row().with_child(Expanded::new(1., text_content).finish());
if let Some(keystroke) = jump_to_toast_keystroke {
row = row.with_child(
self.render_keyboard_shortcut(app, appearance, keystroke),
);
}
row.finish()
} else {
text_content
}
})
.finish(),
)
.finish(),
);
let row = ConstrainedBox::new(row.finish())
.with_max_width(AGENT_TOAST_WIDTH)
.finish();
self.render_container(row, appearance, uuid)
}
fn render_container(
&self,
content: Box<dyn Element>,
appearance: &Appearance,
uuid: Uuid,
) -> Box<dyn Element> {
let navigation_action = WorkspaceAction::FocusTerminalViewInWorkspace {
terminal_view_id: self.terminal_view_id,
};
EventHandler::new(
Hoverable::new(self.container_hover_state.clone(), |mouse_state| {
let container = Container::new(content)
.with_padding(Padding::uniform(AGENT_TOAST_PADDING))
.with_background(appearance.theme().surface_3())
.with_drop_shadow(DropShadow::default())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
AGENT_TOAST_CORNER_RADIUS,
)));
let mut stack = Stack::new().with_child(
SavePosition::new(container.finish(), &self.position_id(uuid)).finish(),
);
let is_close_button_hovered = self
.close_button_hover_state
.lock()
.is_ok_and(|state| state.is_hovered());
if mouse_state.is_hovered() || is_close_button_hovered {
stack.add_positioned_overlay_child(
self.render_close_button(appearance, uuid),
OffsetPositioning::offset_from_save_position_element(
self.position_id(uuid),
vec2f(4., -4.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::TopRight,
ChildAnchor::TopRight,
),
);
}
stack.finish()
})
.on_hover(move |is_hovered, ctx, _, _| {
// Cancel dismissal timeout when hovering
if is_hovered {
ctx.dispatch_typed_action(AgentToastAction::CancelDismissalTimeout(uuid));
} else {
ctx.dispatch_typed_action(AgentToastAction::StartDismissalTimeout(uuid));
}
})
.finish(),
)
.on_left_mouse_down(move |ctx, _, _| {
// Dismiss immediately when clicked on the toast body
ctx.dispatch_typed_action(AgentToastAction::ClickToastBody(uuid));
ctx.dispatch_typed_action(navigation_action.clone());
DispatchEventResult::PropagateToParent
})
.finish()
}
fn render_keyboard_shortcut(
&self,
_app: &AppContext,
appearance: &Appearance,
keystroke: Keystroke,
) -> Box<dyn Element> {
use crate::ui_components::blended_colors;
use warpui::ui_components::keyboard_shortcut::KeyboardShortcut;
let theme = appearance.theme();
let keybinding_style = UiComponentStyles {
font_family_id: Some(appearance.monospace_font_family()),
font_color: Some(blended_colors::text_main(theme, theme.surface_2())),
font_size: Some(appearance.ui_font_size()),
background: Some(theme.surface_2().into()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(3.0))),
padding: Some(Coords {
top: 1.0,
bottom: 1.0,
left: 4.0,
right: 4.0,
}),
..Default::default()
};
Container::new(
KeyboardShortcut::new(&keystroke, keybinding_style)
.build()
.finish(),
)
.with_margin_left(8.)
.finish()
}
fn render_close_button(&self, appearance: &Appearance, uuid: Uuid) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
EventHandler::new(
Hoverable::new(self.close_button_hover_state.clone(), |_| {
Container::new(
ui_builder
.close_button(CLOSE_BUTTON_SIZE, self.close_button_mouse_state.clone())
.with_style(UiComponentStyles {
font_color: Some(appearance.theme().foreground().into()),
background: Some(appearance.theme().surface_2().into()),
border_color: Some(appearance.theme().surface_3().into()),
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
border_width: Some(2.),
padding: Some(Coords {
top: 2.,
bottom: 2.,
left: 2.,
right: 2.,
}),
..Default::default()
})
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AgentToastAction::ClickDismissButton(uuid));
})
.finish(),
)
.finish()
})
.finish(),
)
.on_left_mouse_down(|_, _, _| {
// Stop propagation so the parent toast click handler doesn't get called
DispatchEventResult::StopPropagation
})
.finish()
}
}
+177
View File
@@ -0,0 +1,177 @@
use pathfinder_color::ColorU;
use warp_core::ui::theme::color::internal_colors;
use warpui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Icon,
MainAxisSize, ParentElement, Radius, Shrinkable,
},
ui_components::components::{UiComponent, UiComponentStyles},
Element,
};
use crate::{appearance::Appearance, themes::theme::Fill};
const ALERT_CORNER_RADIUS: f32 = 4.;
const ALERT_VERTICAL_PADDING: f32 = 8.;
const ALERT_HORIZONTAL_PADDING: f32 = 12.;
const ALERT_ICON_RIGHT_MARGIN: f32 = 8.;
const ALERT_ICON_SIZE: f32 = 16.;
const DEFAULT_MAIN_AXIS_SIZE: MainAxisSize = MainAxisSize::Min;
const SUCCESS_ICON_PATH: &str = "bundled/svg/check-skinny.svg";
const ERROR_ICON_PATH: &str = "bundled/svg/alert-circle.svg";
/// Represents the type of alert. Controls color and icon in order to communicate success, error, etc.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum AlertFlavor {
#[default]
Default,
Success,
Error,
Warning,
}
impl AlertFlavor {
pub fn icon_path(&self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::Success => Some(SUCCESS_ICON_PATH),
Self::Error | Self::Warning => Some(ERROR_ICON_PATH),
}
}
pub fn text_color(&self, appearance: &Appearance) -> ColorU {
let theme = appearance.theme();
match self {
AlertFlavor::Default => theme.main_text_color(theme.background()).into(),
AlertFlavor::Warning => theme.ansi_fg_yellow(),
_ => theme.background().into(),
}
}
pub fn bg_color(&self, appearance: &Appearance) -> Fill {
let theme = appearance.theme();
match self {
Self::Default => internal_colors::neutral_4(theme).into(),
Self::Success => theme.ansi_fg_green().into(),
Self::Error => theme.ansi_fg_red().into(),
Self::Warning => theme.yellow_overlay_1(),
}
}
pub fn border_color(&self, appearance: &Appearance) -> Fill {
let theme = appearance.theme();
match self {
AlertFlavor::Default => internal_colors::neutral_3(theme).into(),
AlertFlavor::Success => theme.ansi_bg_green().into(),
AlertFlavor::Error => theme.ansi_bg_red().into(),
AlertFlavor::Warning => Fill::Solid(ColorU::transparent_black()),
}
}
}
/// Configuration passed from parent to control the alert's appearance and behavior
#[derive(Default)]
pub struct AlertConfig {
pub flavor: AlertFlavor,
pub message: String,
pub main_axis_size: Option<MainAxisSize>,
}
/// The main Alert component
#[derive(Clone, Default)]
pub struct Alert;
impl Alert {
pub fn new() -> Self {
Self
}
/// Creates a basic alert without a link.
/// Ergonomic constructor to avoid writing `Alert::<()>::new()`.
pub fn basic() -> Self {
Self::new()
}
pub fn render(&self, config: AlertConfig, appearance: &Appearance) -> Box<dyn Element> {
let content = self.render_simple(&config, appearance);
Container::new(content)
.with_vertical_padding(ALERT_VERTICAL_PADDING)
.with_horizontal_padding(ALERT_HORIZONTAL_PADDING)
.with_background(config.flavor.bg_color(appearance))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(ALERT_CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_fill(config.flavor.border_color(appearance)))
.finish()
}
fn render_simple(&self, config: &AlertConfig, appearance: &Appearance) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
let mut content_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_main_axis_size(config.main_axis_size.unwrap_or(DEFAULT_MAIN_AXIS_SIZE));
if let Some(icon_path) = config.flavor.icon_path() {
content_row.add_child(
Container::new(
ConstrainedBox::new(
Icon::new(icon_path, config.flavor.text_color(appearance)).finish(),
)
.with_max_height(ALERT_ICON_SIZE)
.with_max_width(ALERT_ICON_SIZE)
.finish(),
)
.with_margin_right(ALERT_ICON_RIGHT_MARGIN)
.finish(),
);
}
content_row.add_child(
Shrinkable::new(
1.,
ui_builder
.wrappable_text(config.message.clone(), true)
.with_style(UiComponentStyles {
font_size: Some(appearance.ui_font_size() * 1.2),
font_color: Some(config.flavor.text_color(appearance)),
..Default::default()
})
.build()
.finish(),
)
.finish(),
);
content_row.finish()
}
}
// Convenience methods for creating common alert configurations
impl AlertConfig {
pub fn new(message: String, flavor: AlertFlavor) -> Self {
Self {
flavor,
message,
main_axis_size: None,
}
}
pub fn error(message: String) -> Self {
Self::new(message, AlertFlavor::Error)
}
#[allow(dead_code)]
pub fn success(message: String) -> Self {
Self::new(message, AlertFlavor::Success)
}
pub fn warning(message: String) -> Self {
Self::new(message, AlertFlavor::Warning)
}
pub fn with_main_axis_size(mut self, main_axis_size: MainAxisSize) -> Self {
self.main_axis_size = Some(main_axis_size);
self
}
}
+241
View File
@@ -0,0 +1,241 @@
use pathfinder_color::ColorU;
use warp_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
use warpui::elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, MainAxisSize,
MouseStateHandle, ParentElement, Radius, Stack,
};
use warpui::ui_components::checkbox::Checkbox;
use warpui::ui_components::components::UiComponentStyles;
use warpui::Element;
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
/// Which direction the callout arrow points.
#[derive(Debug, Clone, Copy)]
pub enum CalloutArrowDirection {
Up,
Left,
}
/// Where the arrow is positioned along the bubble edge.
#[derive(Debug, Clone, Copy)]
pub enum CalloutArrowPosition {
/// Offset from the start of the bubble edge the arrow sits on.
/// For Up arrows: offset from the left edge.
/// For Left arrows: offset from the top edge.
Start(f32),
/// Offset from the end of the bubble edge the arrow sits on.
/// For Up arrows: offset from the right edge.
End(f32),
/// Centered on the bubble edge.
Center,
}
/// Configuration for rendering a callout bubble with an arrow.
pub struct CalloutBubbleConfig {
pub width: f32,
pub arrow_direction: CalloutArrowDirection,
pub arrow_position: CalloutArrowPosition,
}
pub fn phenomenon_background_color() -> ColorU {
PhenomenonStyle::background()
}
pub fn phenomenon_foreground_color() -> ColorU {
PhenomenonStyle::foreground()
}
pub fn phenomenon_accent_color() -> ColorU {
PhenomenonStyle::accent()
}
pub fn phenomenon_body_text_color() -> ColorU {
PhenomenonStyle::body_text()
}
pub fn phenomenon_label_text_color() -> ColorU {
PhenomenonStyle::label_text()
}
pub fn phenomenon_disabled_label_text_color() -> ColorU {
PhenomenonStyle::disabled_label_text()
}
pub fn phenomenon_subtle_border_color() -> ColorU {
PhenomenonStyle::subtle_border()
}
/// Returns the shared HOA callout background fill using the Phenomenon palette.
pub fn callout_background_fill(appearance: &Appearance) -> Fill {
let _ = appearance;
PhenomenonStyle::tinted_surface()
}
/// Returns the shared HOA callout border color using the Phenomenon palette.
pub fn callout_border_color(appearance: &Appearance) -> ColorU {
let _ = appearance;
PhenomenonStyle::surface_border()
}
/// Renders a callout bubble with an arrow indicator.
///
/// The bubble has an accent-tinted background with an accent border,
/// and a triangular arrow on the specified edge.
/// The `content` element is placed inside the bubble body.
pub fn render_callout_bubble(
content: Box<dyn Element>,
config: &CalloutBubbleConfig,
appearance: &Appearance,
) -> Box<dyn Element> {
let background = callout_background_fill(appearance);
let border_color = callout_border_color(appearance);
let bubble = ConstrainedBox::new(
Container::new(content)
.with_background(background)
.with_border(Border::all(1.).with_border_fill(Fill::Solid(border_color)))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish(),
)
.with_width(config.width)
.finish();
let (border_icon, fill_icon) = match config.arrow_direction {
CalloutArrowDirection::Up => (Icon::CalloutTriangleBorderUp, Icon::CalloutTriangleFillUp),
CalloutArrowDirection::Left => (
Icon::CalloutTriangleBorderLeft,
Icon::CalloutTriangleFillLeft,
),
};
let triangle = Stack::new()
.with_child(
ConstrainedBox::new(
border_icon
.to_warpui_icon(Fill::Solid(border_color))
.finish(),
)
.with_width(24.)
.with_height(24.)
.finish(),
)
.with_child(
ConstrainedBox::new(fill_icon.to_warpui_icon(background).finish())
.with_width(24.)
.with_height(24.)
.finish(),
)
.finish();
match config.arrow_direction {
CalloutArrowDirection::Up => {
let arrow_margin = match config.arrow_position {
CalloutArrowPosition::Start(offset) => {
Container::new(triangle).with_margin_left(offset)
}
CalloutArrowPosition::End(offset) => {
let margin_left = (config.width - offset - 24.).max(0.);
Container::new(triangle).with_margin_left(margin_left)
}
CalloutArrowPosition::Center => {
let margin_left = (config.width - 24.) / 2.;
Container::new(triangle).with_margin_left(margin_left)
}
};
let mut column = Flex::column().with_main_axis_size(MainAxisSize::Min);
column.add_child(arrow_margin.with_margin_bottom(-3.).finish());
column.add_child(bubble);
column.finish()
}
CalloutArrowDirection::Left => {
let (arrow_margin, cross_axis_alignment) = match config.arrow_position {
CalloutArrowPosition::Start(offset) => (
Container::new(triangle).with_margin_top(offset),
CrossAxisAlignment::Start,
),
CalloutArrowPosition::End(offset) => (
Container::new(triangle).with_margin_top(offset),
CrossAxisAlignment::Start,
),
CalloutArrowPosition::Center => {
(Container::new(triangle), CrossAxisAlignment::Center)
}
};
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(cross_axis_alignment);
row.add_child(arrow_margin.with_margin_right(-3.).finish());
row.add_child(bubble);
row.finish()
}
}
}
/// Title text color for callout content (foreground, 100% opacity).
pub fn callout_title_color(appearance: &Appearance) -> ColorU {
let _ = appearance;
phenomenon_foreground_color()
}
/// Body/description text color for callout content in the Phenomenon palette.
pub fn callout_body_color(appearance: &Appearance) -> ColorU {
let _ = appearance;
phenomenon_body_text_color()
}
/// Label/secondary text color for callout content in the Phenomenon palette.
pub fn callout_label_color(appearance: &Appearance) -> ColorU {
let _ = appearance;
phenomenon_label_text_color()
}
/// Creates a checkbox styled for callout bubbles using the Phenomenon palette.
///
/// Unchecked: foreground border, no fill.
/// Checked: foreground fill, background-colored check icon.
pub fn callout_checkbox(
mouse_state: MouseStateHandle,
size: Option<f32>,
appearance: &Appearance,
) -> Checkbox {
let _ = appearance;
let foreground_color = phenomenon_foreground_color();
let foreground_fill = Fill::Solid(foreground_color);
let background_color = phenomenon_background_color();
let disabled_color = phenomenon_subtle_border_color();
let checkbox_size = size.or(Some(12.));
let corner_radius = CornerRadius::with_all(Radius::Pixels(2.));
Checkbox::new(
mouse_state,
UiComponentStyles {
font_size: checkbox_size,
border_color: Some(Fill::Solid(foreground_color).into()),
font_color: Some(foreground_color),
border_width: Some(1.),
border_radius: Some(corner_radius),
..Default::default()
},
None,
Some(UiComponentStyles {
font_size: checkbox_size,
background: Some(foreground_fill.into()),
border_color: Some(foreground_fill.into()),
font_color: Some(background_color),
border_radius: Some(corner_radius),
..Default::default()
}),
Some(UiComponentStyles {
font_size: checkbox_size,
border_color: Some(Fill::Solid(disabled_color).into()),
font_color: Some(disabled_color),
border_width: Some(1.),
border_radius: Some(corner_radius),
..Default::default()
}),
)
}
@@ -0,0 +1,183 @@
use crate::{
appearance::Appearance,
editor::{EditorOptions, EditorView, Event as EditorEvent, TextOptions},
};
use warpui::{
elements::{Container, CornerRadius, Dismiss, MouseStateHandle, Radius},
fonts::Weight,
platform::Cursor,
ui_components::{
button::ButtonVariant,
components::{UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
/// This View is a text that can be hovered over. Upon clicking,
/// the text becomes a text input that can be submitted
/// by hitting enter or clicking outside the input.
pub struct ClickableTextInput {
text: String,
text_button_mouse_handle: MouseStateHandle,
show_text_as_hoverable: bool,
editor: ViewHandle<EditorView>,
}
impl ClickableTextInput {
pub fn new(text: String, ctx: &mut ViewContext<Self>) -> Self {
let editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let options = EditorOptions {
autogrow: true,
soft_wrap: true,
text: TextOptions::ui_text(None, appearance),
..Default::default()
};
EditorView::new(options, ctx)
});
ctx.subscribe_to_view(&editor, Self::handle_editor_event);
Self {
text,
text_button_mouse_handle: Default::default(),
show_text_as_hoverable: true,
editor,
}
}
pub fn set_placeholder_text(&mut self, text: impl Into<String>, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(text, ctx);
});
}
fn submit_input(&mut self, ctx: &mut ViewContext<Self>) {
let content = self
.editor
.read(ctx, |editor, ctx| editor.buffer_text(ctx).trim().to_owned());
if !content.is_empty() {
ctx.emit(ClickableTextInputEvent::Submit(content));
}
self.show_text_as_hoverable = true;
ctx.notify();
}
fn handle_editor_event(
&mut self,
_handle: ViewHandle<EditorView>,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
EditorEvent::Enter => {
self.submit_input(ctx);
}
EditorEvent::Edited(_) => {
ctx.notify();
}
_ => {}
}
}
}
impl View for ClickableTextInput {
fn ui_name() -> &'static str {
"ClickableTextInput"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
if self.show_text_as_hoverable {
appearance
.ui_builder()
.button(ButtonVariant::Text, self.text_button_mouse_handle.clone())
.with_centered_text_label(self.text.clone())
.with_style(UiComponentStyles {
font_color: Some(appearance.theme().active_ui_text_color().into()),
font_weight: Some(Weight::Bold),
font_size: Some(24.),
..Default::default()
})
.with_hovered_styles(UiComponentStyles {
font_color: Some(appearance.theme().nonactive_ui_text_color().into()),
..Default::default()
})
.build()
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ClickableTextInputAction::ShowEditor)
})
.finish()
} else {
let current_theme = appearance.theme();
let input_box = Container::new(
Dismiss::new(
appearance
.ui_builder()
.text_input(self.editor.clone())
.with_style(UiComponentStyles {
width: Some(200.),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
border_width: Some(1.),
border_color: Some(current_theme.accent_button_color().into()),
background: Some(current_theme.surface_2().into_solid().into()),
..Default::default()
})
.build()
.finish(),
)
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(ClickableTextInputAction::Submit))
.finish(),
);
input_box.finish()
}
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus(&self.editor);
ctx.notify();
}
}
}
#[derive(Debug)]
pub enum ClickableTextInputEvent {
Submit(String),
}
impl Entity for ClickableTextInput {
type Event = ClickableTextInputEvent;
}
#[derive(Debug)]
pub enum ClickableTextInputAction {
ShowEditor,
UpdateText(String),
Submit,
}
impl TypedActionView for ClickableTextInput {
type Action = ClickableTextInputAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ClickableTextInputAction::ShowEditor => {
self.show_text_as_hoverable = false;
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
ctx.focus(&self.editor);
ctx.notify();
}
ClickableTextInputAction::UpdateText(new_text) => {
self.text = new_text.to_string();
ctx.notify();
}
ClickableTextInputAction::Submit => {
self.submit_input(ctx);
}
}
}
}
+301
View File
@@ -0,0 +1,301 @@
use super::dropdown::DropdownAction;
use crate::{
appearance::Appearance,
menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields, MenuVariant},
themes::theme::Fill,
ui_components::icons::Icon,
};
use pathfinder_geometry::vector::vec2f;
use warpui::{
elements::{
Border, ChildAnchor, ConstrainedBox, CornerRadius, CrossAxisAlignment, Flex,
Icon as WarpUiIcon, MainAxisAlignment, MouseStateHandle, OffsetPositioning, ParentElement,
PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, Stack,
},
presenter::ChildView,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
Action, AppContext, BlurContext, Element, Entity, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
#[cfg(test)]
#[path = "compact_dropdown_tests.rs"]
mod tests;
/// A compact dropdown view. Each item has a corresponding icon, which is shown
/// when the dropdown is closed.
///
/// This is useful instead of [`crate::dropdown::Dropdown`] when showing a
/// dropdown alongside other controls, such as in a formatting UI.
pub struct CompactDropdown<A: Action + Clone> {
/// Whether the dropdown is open.
is_expanded: bool,
/// Mouse state for the dropdown button.
top_bar_mouse_state: MouseStateHandle,
/// Dropdown menu.
dropdown: ViewHandle<Menu<DropdownAction<A>>>,
/// The size that icons are scaled to. Defaults to the UI font size if not set.
icon_size: Option<f32>,
}
pub struct CompactDropdownItem<A: Action + Clone> {
/// Icon identifier for this item.
icon: Icon,
/// Optional override color for the icon.
icon_color: Option<Fill>,
/// Text to display for this item when the dropdown is open.
display_text: String,
/// Typed action dispatched when this item is selected.
action: A,
}
impl<A: Action + Clone> CompactDropdown<A> {
/// Create a new, empty compact dropdown. The [`MenuVariant`] determines whether or not the
/// dropdown is scrollable when expanded.
pub fn new(menu_variant: MenuVariant, ctx: &mut ViewContext<Self>) -> Self {
let dropdown = ctx.add_typed_action_view(|ctx| {
let theme = Appearance::as_ref(ctx).theme();
Menu::new()
.with_menu_variant(menu_variant)
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.prevent_interaction_with_other_elements()
});
ctx.subscribe_to_view(&dropdown, move |me, _, event, ctx| {
me.handle_menu_event(event, ctx);
});
Self {
is_expanded: false,
dropdown,
top_bar_mouse_state: Default::default(),
icon_size: None,
}
}
/// Sets the size of the icons in the dropdown top bar. This defaults
/// to the UI font size.
pub fn set_icon_size(&mut self, icon_size: f32) {
self.icon_size = Some(icon_size);
}
/// Replaces the items in the dropdown.
pub fn set_items(
&mut self,
items: impl IntoIterator<Item = CompactDropdownItem<A>>,
ctx: &mut ViewContext<Self>,
) {
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_items(items.into_iter().map(CompactDropdownItem::menu_item), ctx);
});
ctx.notify();
}
/// Change the selected item by name, if it exists.
pub fn set_selected_by_name(
&mut self,
selected_item: impl AsRef<str>,
ctx: &mut ViewContext<Self>,
) {
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_name(selected_item, ctx);
ctx.notify();
});
ctx.notify();
}
/// Render an icon at the configured icon size.
fn render_sized_icon(&self, appearance: &Appearance, icon: WarpUiIcon) -> Box<dyn Element> {
let icon_size = self.icon_size.unwrap_or(appearance.ui_font_size());
ConstrainedBox::new(icon.finish())
.with_width(icon_size)
.with_height(icon_size)
.finish()
}
/// Render the top bar, the part of the dropdown that is always visible.
fn render_top_bar(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut button_label = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(MenuItem::Item(fields)) = self.dropdown.as_ref(app).selected_item() {
if let Some(icon) = fields.icon() {
let icon_color = fields
.override_icon_color()
.unwrap_or_else(|| appearance.theme().active_ui_text_color());
button_label
.add_child(self.render_sized_icon(appearance, icon.to_warpui_icon(icon_color)));
}
}
button_label.add_child(self.render_sized_icon(
appearance,
WarpUiIcon::new(
"bundled/svg/chevron-down.svg",
appearance.theme().active_ui_text_color(),
),
));
let mut top_bar = appearance
.ui_builder()
.button(ButtonVariant::Text, self.top_bar_mouse_state.clone())
.with_custom_label(button_label.finish())
.set_clicked_styles(None)
.with_style(UiComponentStyles {
padding: Some(Coords::uniform(4.)),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
..Default::default()
})
.with_hovered_styles(UiComponentStyles {
background: Some(appearance.theme().surface_3().into()),
..Default::default()
})
.build();
// See the Dropdown implementation for why this callback is only added
// if the dropdown is not expanded.
if !self.is_expanded {
top_bar = top_bar.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(DropdownAction::<A>::ToggleExpanded);
});
}
SavePosition::new(top_bar.finish(), &self.top_bar_label()).finish()
}
/// Saved position label for the top bar, used to position the expanded menu.
fn top_bar_label(&self) -> String {
format!("compact_dropdown_top_bar_{}", self.dropdown.id())
}
fn handle_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
match event {
MenuEvent::Close { via_select_item: _ } => self.close(ctx),
MenuEvent::ItemSelected => {
// If the selection changes, we should re-render, but don't need
// to do anything else unless the item is actively clicked.
ctx.notify();
}
MenuEvent::ItemHovered => {}
}
}
/// Toggles whether or not the dropdown is expanded.
fn toggle_expanded(&mut self, ctx: &mut ViewContext<Self>) {
self.is_expanded = !self.is_expanded;
if self.is_expanded {
ctx.focus(&self.dropdown);
}
ctx.notify();
}
fn focus(&mut self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.dropdown);
ctx.notify();
}
/// Adapter between [`MenuItem`] click callbacks and the parent action type.
/// When a dropdown menu item is selected, we dispatch its action and close
/// the dropdown.
fn select_action_and_close(&mut self, action: &A, ctx: &mut ViewContext<Self>) {
ctx.dispatch_typed_action(action);
self.close(ctx);
}
/// Close the dropdown.
fn close(&mut self, ctx: &mut ViewContext<Self>) {
self.is_expanded = false;
ctx.notify();
ctx.emit(CompactDropdownEvent::Close);
}
}
impl<A: Action + Clone> Entity for CompactDropdown<A> {
type Event = CompactDropdownEvent;
}
impl<A: Action + Clone> View for CompactDropdown<A> {
fn ui_name() -> &'static str {
"CompactDropdown"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let mut dropdown_stack = Stack::new().with_child(self.render_top_bar(app));
if self.is_expanded {
dropdown_stack.add_positioned_overlay_child(
ChildView::new(&self.dropdown).finish(),
OffsetPositioning::offset_from_save_position_element(
self.top_bar_label(),
vec2f(0., 0.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
}
dropdown_stack.finish()
}
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
if blur_ctx.is_self_blurred() {
self.close(ctx);
}
}
}
impl<A: Action + Clone> TypedActionView for CompactDropdown<A> {
type Action = DropdownAction<A>;
fn handle_action(&mut self, action: &DropdownAction<A>, ctx: &mut ViewContext<Self>) {
match action {
DropdownAction::Focus(_) => self.focus(ctx),
DropdownAction::Close => self.close(ctx),
DropdownAction::SelectActionAndClose(action) => {
self.select_action_and_close(action, ctx)
}
DropdownAction::ToggleExpanded => self.toggle_expanded(ctx),
}
}
}
impl<A: Action + Clone> CompactDropdownItem<A> {
pub fn new(icon: Icon, display_text: impl Into<String>, action: A) -> Self {
Self {
display_text: display_text.into(),
icon,
icon_color: None,
action,
}
}
/// Override the fill of the item's icon. If not set, the default is to
/// match the active text color.
pub fn with_icon_color(mut self, color: Fill) -> Self {
self.icon_color = Some(color);
self
}
fn menu_item(self) -> MenuItem<DropdownAction<A>> {
let mut item = MenuItemFields::new(self.display_text)
.with_icon(self.icon)
.with_on_select_action(DropdownAction::SelectActionAndClose(self.action));
if let Some(color) = self.icon_color {
item = item.with_override_icon_color(color);
}
item.into_item()
}
}
/// Events sent from the [`CompactDropdown`] to its parent.
pub enum CompactDropdownEvent {
/// Sent when the dropdown is closed. Generally, the parent view will take back focus when this happens.
Close,
}
@@ -0,0 +1,35 @@
use warp_core::ui::appearance::Appearance;
use warpui::{platform::WindowStyle, App, View};
use crate::{menu::MenuVariant, ui_components::icons::Icon};
use super::{CompactDropdown, CompactDropdownItem};
#[derive(Debug, Clone)]
struct TestAction;
/// Baseline test that the view can render.
#[test]
fn test_render() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| Appearance::mock());
let (_, view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
CompactDropdown::<TestAction>::new(MenuVariant::Fixed, ctx)
});
// This should not panic.
view.read(&app, |view, ctx| view.render(ctx));
// After adding some items, rendering should still not panic.
view.update(&mut app, |view, ctx| {
view.set_items(
[
CompactDropdownItem::new(Icon::Folder, "Folder", TestAction),
CompactDropdownItem::new(Icon::Gear, "Gear", TestAction),
],
ctx,
);
});
view.read(&app, |view, ctx| view.render(ctx));
})
}
@@ -0,0 +1,229 @@
use std::sync::Arc;
use crate::{
ai::blocklist::inline_action::inline_action_icons::icon_size,
ui_components::icons::Icon,
view_components::action_button::{
ActionButton, ActionButtonTheme, AdjoinedSide, ButtonSize, KeystrokeSource,
},
};
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::{
ChildView, ConstrainedBox, Container, CrossAxisAlignment, Flex, MainAxisAlignment,
MainAxisSize, ParentElement,
},
Action, AppContext, Element, TypedActionView, View, ViewContext, ViewHandle,
};
const BUTTON_MARGIN: f32 = 8.;
// Size switch thresholds for responsive button behavior
pub const SMALL_SIZE_SWITCH_THRESHOLD: f32 = 400.0;
pub const MEDIUM_SIZE_SWITCH_THRESHOLD: f32 = 500.0;
pub const LARGE_SIZE_SWITCH_THRESHOLD: f32 = 600.0;
pub const XLARGE_SIZE_SWITCH_THRESHOLD: f32 = 650.0;
/// Stores normal and compact (i.e. without a keybinding display) versions of action buttons
/// for use in views that need to display buttons in different modes.
#[derive(Clone)]
pub struct CompactibleActionButton {
compact_button: ViewHandle<ActionButton>,
expanded_button: ViewHandle<ActionButton>,
}
pub trait RenderCompactibleActionButton {
fn render_expanded_button(&self) -> Box<dyn Element>;
fn render_compact_button(&self) -> Box<dyn Element>;
}
impl CompactibleActionButton {
/// Creates a new button pair with compact and regular variants.
pub fn new<T, A>(
label: String,
keybinding: Option<KeystrokeSource>,
size: ButtonSize,
action: A,
compact_icon: Icon,
theme: Arc<dyn ActionButtonTheme>,
ctx: &mut ViewContext<'_, T>,
) -> Self
where
T: TypedActionView<Action = A> + View,
A: Action + Clone + 'static,
{
let action_for_compact = action.clone();
let compact_button = ctx.add_typed_action_view(|ctx| {
let mut compact_button =
ActionButton::new_with_boxed_theme(String::new(), Arc::clone(&theme))
.with_size(size)
.with_icon(compact_icon)
.with_tooltip(label.clone())
.on_click(move |ctx| ctx.dispatch_typed_action(action_for_compact.clone()));
if let Some(ref kb) = keybinding {
if let Some(tooltip_sublabel) = kb.displayed(ctx) {
compact_button = compact_button.with_tooltip_sublabel(tooltip_sublabel);
}
}
compact_button
});
let expanded_button = ctx.add_typed_action_view(move |ctx| {
let mut button = ActionButton::new_with_boxed_theme(label.clone(), theme)
.with_size(size)
.on_click(move |ctx| ctx.dispatch_typed_action(action.clone()));
if let Some(kb) = keybinding {
button = button.with_keybinding(kb, ctx);
}
button
});
Self {
compact_button,
expanded_button,
}
}
pub fn set_label<T: View>(&mut self, label: String, ctx: &mut ViewContext<T>) {
self.expanded_button.update(ctx, |button, ctx| {
button.set_label(label.clone(), ctx);
});
self.compact_button.update(ctx, |button, ctx| {
button.set_tooltip(Some(label), ctx);
});
}
pub fn set_keybinding<T: View>(
&mut self,
keybinding: Option<KeystrokeSource>,
ctx: &mut ViewContext<T>,
) {
self.expanded_button.update(ctx, |button, ctx| {
button.set_keybinding(keybinding.clone(), ctx);
});
self.compact_button.update(ctx, |button, ctx| {
if let Some(keybinding) = keybinding {
button.set_tooltip_sublabel(keybinding.displayed(ctx), ctx);
} else {
button.set_tooltip_sublabel(None::<String>, ctx);
}
});
}
pub fn set_adjoined_side<T: View>(
&mut self,
adjoined_side: AdjoinedSide,
ctx: &mut ViewContext<T>,
) {
self.compact_button.update(ctx, |button, ctx| {
button.set_adjoined_side(adjoined_side, ctx);
});
self.expanded_button.update(ctx, |button, ctx| {
button.set_adjoined_side(adjoined_side, ctx);
});
}
pub fn compact_button(&self) -> &ViewHandle<ActionButton> {
&self.compact_button
}
pub fn expanded_button(&self) -> &ViewHandle<ActionButton> {
&self.expanded_button
}
}
impl RenderCompactibleActionButton for CompactibleActionButton {
fn render_expanded_button(&self) -> Box<dyn Element> {
ChildView::new(self.expanded_button()).finish()
}
fn render_compact_button(&self) -> Box<dyn Element> {
ChildView::new(self.compact_button()).finish()
}
}
/// Render both compact and expanded button rows
/// and then switch between them based on the container width.
pub fn render_compact_and_regular_button_rows(
buttons: Vec<&dyn RenderCompactibleActionButton>,
// None when we don't want to show the expansion icon at all.
expansion_icon_state: Option<bool>,
appearance: &Appearance,
app: &AppContext,
) -> (Box<dyn Element>, Box<dyn Element>) {
let (full_buttons, compact_buttons) = buttons
.iter()
.map(|button| {
(
button.render_expanded_button(),
button.render_compact_button(),
)
})
.unzip();
let mut full_row = render_button_row(full_buttons);
let mut compact_row = render_button_row(compact_buttons);
if let Some(expansion_icon_state) = expansion_icon_state {
full_row.add_child(render_expansion_icon(
expansion_icon_state,
false,
appearance,
app,
));
compact_row.add_child(render_expansion_icon(
expansion_icon_state,
false,
appearance,
app,
));
}
(full_row.finish(), compact_row.finish())
}
fn render_button_row(buttons: Vec<Box<dyn Element>>) -> Flex {
let mut row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
for (index, element) in buttons.into_iter().enumerate() {
let mut container = Container::new(element);
if index != 0 {
container = container.with_margin_left(BUTTON_MARGIN);
}
row.add_child(container.finish());
}
row
}
pub fn render_expansion_icon(
expanded: bool,
expands_upwards: bool,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
ConstrainedBox::new(
warpui::elements::Icon::new(
if expanded {
if expands_upwards {
Icon::ChevronUp.into()
} else {
Icon::ChevronDown.into()
}
} else {
Icon::ChevronRight.into()
},
appearance.theme().foreground(),
)
.finish(),
)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish()
}
@@ -0,0 +1,119 @@
use std::sync::Arc;
use warpui::elements::{ChildView, Flex, ParentElement, SavePosition};
use warpui::{Action, Element, TypedActionView, View, ViewContext, ViewHandle};
use crate::view_components::action_button::AdjoinedSide;
use crate::view_components::compactible_action_button::RenderCompactibleActionButton;
use crate::{
ui_components::icons::Icon,
view_components::action_button::{
ActionButton, ButtonSize, KeystrokeSource, NakedTheme, PrimaryRightBiasedTheme,
PrimaryTheme,
},
view_components::compactible_action_button::CompactibleActionButton,
};
/// A split button composed of a primary CompactibleActionButton and a trailing
/// icon-only menu button (chevron-down). The menu button may be used as an anchor
/// for a dropdown Menu via `with_menu`.
#[derive(Clone)]
#[allow(dead_code)]
pub struct CompactibleSplitActionButton {
primary_button: CompactibleActionButton,
menu_button: ViewHandle<ActionButton>,
save_position_id: Option<String>,
}
impl CompactibleSplitActionButton {
/// Creates a split button: a primary CompactibleActionButton and an icon-only
/// chevron menu button that inherits the same theme choice.
#[allow(dead_code)]
#[allow(clippy::too_many_arguments)]
pub fn new<T, A>(
label: String,
keybinding: Option<KeystrokeSource>,
size: ButtonSize,
action: A,
menu_action: A,
compact_icon: Icon,
use_primary_theme: bool,
save_position_id: Option<String>,
ctx: &mut ViewContext<'_, T>,
) -> Self
where
T: TypedActionView<Action = A> + View,
A: Action + Clone + 'static,
{
let mut primary_button = CompactibleActionButton::new(
label,
keybinding,
size,
action,
compact_icon,
if use_primary_theme {
Arc::new(PrimaryTheme)
} else {
Arc::new(NakedTheme)
},
ctx,
);
primary_button.set_adjoined_side(AdjoinedSide::Right, ctx);
// The down-caret icon-only menu button.
let menu_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new_with_boxed_theme(
"",
if use_primary_theme {
Arc::new(PrimaryRightBiasedTheme)
} else {
Arc::new(NakedTheme)
},
)
.with_size(size)
.with_icon(Icon::ChevronDown)
.with_adjoined_side(AdjoinedSide::Left)
.on_click(move |ctx| ctx.dispatch_typed_action(menu_action.clone()))
});
Self {
primary_button,
menu_button,
save_position_id,
}
}
fn render_button(&self, is_expanded: bool) -> Box<dyn Element> {
let button = if is_expanded {
self.primary_button.expanded_button()
} else {
self.primary_button.compact_button()
};
let row = Flex::row()
.with_child(ChildView::new(button).finish())
.with_child(ChildView::new(&self.menu_button).finish());
if let Some(save_position_id) = &self.save_position_id {
SavePosition::new(row.finish(), save_position_id).finish()
} else {
row.finish()
}
}
pub fn set_keybinding<T: View>(
&mut self,
keybinding: Option<KeystrokeSource>,
ctx: &mut ViewContext<T>,
) {
self.primary_button.set_keybinding(keybinding, ctx);
}
}
impl RenderCompactibleActionButton for CompactibleSplitActionButton {
fn render_expanded_button(&self) -> Box<dyn Element> {
self.render_button(true)
}
fn render_compact_button(&self) -> Box<dyn Element> {
self.render_button(false)
}
}
@@ -0,0 +1,207 @@
//! A reusable component for displaying text with a copy button that shows
//! checkmark feedback when clicked.
use instant::Instant;
use std::time::Duration;
use warpui::elements::{
ConstrainedBox, Container, CrossAxisAlignment, Element, Expanded, Flex, MouseStateHandle,
ParentElement, Shrinkable, Text,
};
use warpui::text_layout::ClipConfig;
use warpui::ui_components::components::UiComponent;
use warpui::{AppContext, SingletonEntity};
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use warpui::color::ColorU;
/// Duration to show the checkmark after copying.
pub const COPY_FEEDBACK_DURATION: Duration = Duration::from_secs(2);
/// Configuration for the copyable text field.
pub struct CopyableTextFieldConfig<'a> {
/// The text to display.
pub text: String,
/// Font size for the text.
pub font_size: f32,
/// Text color (optional - defaults to theme's active_ui_text_color if not set).
pub text_color: Option<ColorU>,
/// Size of the copy button icon.
pub icon_size: f32,
/// Mouse state handle for the copy button.
pub copy_button_mouse_state: MouseStateHandle,
/// When the text was last copied (for showing checkmark feedback).
pub last_copied_at: Option<&'a Instant>,
/// Whether the text should be selectable.
pub is_selectable: bool,
/// Whether the text should soft-wrap instead of being ellipsized.
pub wrap_text: bool,
/// Placement of the copy button relative to the text.
pub copy_button_placement: CopyButtonPlacement,
/// Cross-axis alignment of the row (text + copy button).
pub cross_axis_alignment: Option<CrossAxisAlignment>,
}
#[derive(Clone, Copy)]
pub enum CopyButtonPlacement {
NextToText,
EndOfContainer,
}
impl<'a> CopyableTextFieldConfig<'a> {
/// Creates a new config with the given text.
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
font_size: 14.0,
text_color: None,
icon_size: 12.0,
copy_button_mouse_state: MouseStateHandle::default(),
last_copied_at: None,
is_selectable: true,
wrap_text: false,
copy_button_placement: CopyButtonPlacement::EndOfContainer,
cross_axis_alignment: None,
}
}
/// Sets the font size.
pub fn with_font_size(mut self, font_size: f32) -> Self {
self.font_size = font_size;
self
}
/// Sets the text color.
pub fn with_text_color(mut self, color: ColorU) -> Self {
self.text_color = Some(color);
self
}
/// Sets the icon size.
pub fn with_icon_size(mut self, icon_size: f32) -> Self {
self.icon_size = icon_size;
self
}
/// Sets whether the text should soft-wrap instead of being ellipsized.
pub fn with_wrap_text(mut self, wrap_text: bool) -> Self {
self.wrap_text = wrap_text;
self
}
/// Sets the mouse state handle for the copy button.
pub fn with_mouse_state(mut self, mouse_state: MouseStateHandle) -> Self {
self.copy_button_mouse_state = mouse_state;
self
}
/// Sets when the text was last copied (for checkmark feedback).
pub fn with_last_copied_at(mut self, last_copied_at: Option<&'a Instant>) -> Self {
self.last_copied_at = last_copied_at;
self
}
/// Sets the placement of the copy button relative to the text.
pub fn with_copy_button_placement(mut self, placement: CopyButtonPlacement) -> Self {
self.copy_button_placement = placement;
self
}
/// Sets the cross-axis alignment of the row.
pub fn with_cross_axis_alignment(mut self, alignment: CrossAxisAlignment) -> Self {
self.cross_axis_alignment = Some(alignment);
self
}
/// Returns true if the checkmark feedback should be shown.
pub fn should_show_checkmark(&self) -> bool {
self.last_copied_at
.is_some_and(|time| time.elapsed() < COPY_FEEDBACK_DURATION)
}
}
/// Renders a text field with a copy button that shows checkmark feedback.
///
/// The copy action must be handled by the caller via the `on_copy` callback.
/// The caller is also responsible for tracking `last_copied_at` and scheduling
/// a re-render after `COPY_FEEDBACK_DURATION` to clear the checkmark.
///
/// # Example
/// ```ignore
/// let element = render_copyable_text_field(
/// CopyableTextFieldConfig::new("some text to copy")
/// .with_font_size(14.0)
/// .with_text_color(theme.active_ui_text_color())
/// .with_mouse_state(mouse_state.clone())
/// .with_last_copied_at(last_copied_times.get(&id)),
/// |ctx| {
/// ctx.clipboard().write(ClipboardContent::plain_text("some text to copy"));
/// // Track the copy time and schedule re-render
/// },
/// app,
/// );
/// ```
pub fn render_copyable_text_field<F>(
config: CopyableTextFieldConfig,
on_copy: F,
app: &AppContext,
) -> Box<dyn Element>
where
F: FnMut(&mut warpui::EventContext) + 'static,
{
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let show_checkmark = config.should_show_checkmark();
let text_color = config
.text_color
.unwrap_or_else(|| theme.active_ui_text_color().into());
let text_element = if config.wrap_text {
Text::new(config.text, appearance.ui_font_family(), config.font_size)
.with_color(text_color)
.with_selectable(config.is_selectable)
.finish()
} else {
Text::new_inline(config.text, appearance.ui_font_family(), config.font_size)
.with_color(text_color)
.with_selectable(config.is_selectable)
.with_clip(ClipConfig::ellipsis())
.finish()
};
let copy_button: Box<dyn Element> = if show_checkmark {
// Show green checkmark
let check_icon = warpui::elements::Icon::new(Icon::Check.into(), theme.ansi_fg_green());
ConstrainedBox::new(check_icon.finish())
.with_width(config.icon_size)
.with_height(config.icon_size)
.finish()
} else {
// Show copy button
let mut on_copy = on_copy;
appearance
.ui_builder()
.copy_button(config.icon_size, config.copy_button_mouse_state)
.build()
.on_click(move |ctx, _, _| {
on_copy(ctx);
})
.finish()
};
let cross_axis_alignment = config
.cross_axis_alignment
.unwrap_or(CrossAxisAlignment::Center);
let mut row = Flex::row().with_cross_axis_alignment(cross_axis_alignment);
match config.copy_button_placement {
CopyButtonPlacement::NextToText => {
row.add_child(Shrinkable::new(1., text_element).finish());
row.add_child(Container::new(copy_button).with_padding_left(4.).finish());
}
CopyButtonPlacement::EndOfContainer => {
row.add_child(Expanded::new(1., text_element).finish());
row.add_child(Container::new(copy_button).with_padding_left(4.).finish());
}
}
row.finish()
}
@@ -0,0 +1,592 @@
use std::rc::Rc;
use std::time::Duration;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use uuid::Uuid;
use warp_core::ui::builder::UiBuilder;
use warp_core::ui::theme::color::internal_colors;
use warpui::elements::ChildView;
use warpui::keymap::Keystroke;
use warpui::r#async::Timer;
use warpui::{
elements::{
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DispatchEventResult, EventHandler, Flex, Hoverable, Icon, MainAxisAlignment, MainAxisSize,
MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor,
PositionedElementOffsetBounds, Radius, SavePosition, Shrinkable, Stack,
},
fonts::Weight,
r#async::SpawnedFutureHandle,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use warpui::{Action, ViewHandle};
use crate::{appearance::Appearance, themes::theme::Fill};
use super::action_button::ActionButton;
const TOAST_WIDTH: f32 = 464.;
const TOAST_CORNER_RADIUS: f32 = 4.;
const TEXT_MARGIN: f32 = 16.;
const VERTICAL_PADDING: f32 = 8.;
const HORIZONTAL_PADDING: f32 = 12.;
const ICON_RIGHT_MARGIN: f32 = 8.;
const CLOSE_BUTTON_SIZE: f32 = 16.;
const SUCCESS_ICON_PATH: &str = "bundled/svg/check-skinny.svg";
const ERROR_ICON_PATH: &str = "bundled/svg/alert-circle.svg";
struct ToastData<A: Action + Clone> {
/// The toast itself.
dismissible_toast: DismissibleToast<A>,
/// Each toast is stored with its abort handle so we can abort the
/// timeout-based dismissal if a manual dismissal happens first.
abort_handle: Option<SpawnedFutureHandle>,
/// Unique identifier for the toast. Used for finding the toast to dismiss from the
/// stack.
uuid: Uuid,
}
/// This View is a stack of toasts, each of which holds some "main text" on the left, and optionally
/// a hyperlink on the right. They can either be manually dismissed by clicking the X button, or
/// automatically dismissed by a timeout (of configurable duration). It is a stack b/c there may be
/// multiple toasts in existence (one might get added before a previous one is dismissed), and should
/// be rendered according to the order they were generated.
pub struct DismissibleToastStack<A: Action + Clone = ()> {
timeout: Duration,
/// A vector of individual toasts. Manual dismissals dismiss the specific toast that was
/// clicked, while timeouts pass the toast's UUID to the dismiss method.
/// Since the user may close any arbitrary toast, we use a vector, and assign UUIDs to
/// each toast to identify them. Each toast is stored together with its abort handle
/// so we can abort the timeout-based dismissal if a manual dismissal happens.
toasts: Vec<ToastData<A>>,
}
impl<A: Action + Clone> DismissibleToastStack<A> {
pub fn new(timeout: Duration) -> Self {
Self {
timeout,
toasts: Vec::new(),
}
}
/// Put a new ephemeral toast in the front of the stack.
/// The toast will go away when:
/// - the configurable timeout is reached
/// - the toast is manually dismissed
/// whichever comes first.
pub fn add_ephemeral_toast(&mut self, toast: DismissibleToast<A>, ctx: &mut ViewContext<Self>) {
let uuid = Uuid::new_v4();
let abort_handle = ctx.spawn_abortable(
Timer::after(self.timeout),
move |view, _, ctx| view.dismiss_toast_by_uuid(&uuid, ctx),
|_, _| {},
);
if let Some(object_id) = &toast.object_id {
self.dismiss_older_toasts(object_id, ctx);
}
self.toasts.push(ToastData {
dismissible_toast: toast,
abort_handle: Some(abort_handle),
uuid,
});
ctx.notify();
}
/// Put a new persistent toast at the top of the stack.
/// The toast will only go away when the toast is manually dismissed.
pub fn add_persistent_toast(
&mut self,
toast: DismissibleToast<A>,
ctx: &mut ViewContext<Self>,
) {
if let Some(object_id) = &toast.object_id {
self.dismiss_older_toasts(object_id, ctx);
}
self.toasts.push(ToastData {
dismissible_toast: toast,
abort_handle: None,
uuid: Uuid::new_v4(),
});
ctx.notify();
}
/// Find a toast by uuid and removed it from the stack.
pub fn dismiss_toast_by_uuid(&mut self, uuid: &Uuid, ctx: &mut ViewContext<Self>) {
if let Some(index) = self.toasts.iter().position(|toast| toast.uuid == *uuid) {
let toast = self.toasts.remove(index);
if let Some(abort_handle) = toast.abort_handle {
abort_handle.abort();
}
ctx.notify();
}
}
/// Find all toasts pertaining to a particular object, and remove them from the stack.
pub fn dismiss_older_toasts(&mut self, object_id: &str, ctx: &mut ViewContext<Self>) {
self.toasts.retain(|toast| {
if let Some(other_object_id) = &toast.dismissible_toast.object_id {
return object_id != other_object_id;
}
true
});
ctx.notify();
}
/// Dismiss all toasts whose `object_id` starts with the given prefix.
pub fn dismiss_toasts_by_prefix(&mut self, prefix: &str, ctx: &mut ViewContext<Self>) {
let before = self.toasts.len();
self.toasts.retain(|toast| {
toast
.dismissible_toast
.object_id
.as_ref()
.is_none_or(|id| !id.starts_with(prefix))
});
if self.toasts.len() != before {
ctx.notify();
}
}
pub fn clear_toasts(&mut self, ctx: &mut ViewContext<Self>) {
self.toasts.clear();
ctx.notify();
}
/// Returns whether the stack currently has any toasts.
pub fn has_toasts(&self) -> bool {
!self.toasts.is_empty()
}
}
impl<A: Action + Clone> View for DismissibleToastStack<A> {
fn ui_name() -> &'static str {
"DismissibleToastStack"
}
/// Shows nothing if there are no toasts. If there are one or more, show them all in a
/// stacked column with the most recent one at the top.
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let mut rendered_toasts =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Center);
// For loop over the toasts in reverse order so that the most recent toast is
// rendered first. Pass in the toast's UUID to the render method so that it is
// piped to the dismiss action when the close button is clicked. The handler will
// use this UUID to determine which toast in the stack to close.
for toast in self.toasts.iter().rev() {
rendered_toasts.add_child(
Container::new(toast.dismissible_toast.render(app, toast.uuid))
.with_margin_bottom(5.)
.finish(),
);
}
rendered_toasts.finish()
}
}
impl<A: Action + Clone> Entity for DismissibleToastStack<A> {
type Event = ();
}
#[derive(Debug)]
pub enum DismissibleToastAction {
ClickDismissButton(Uuid),
ClickBody(Uuid),
}
impl<A: Action + Clone> TypedActionView for DismissibleToastStack<A> {
type Action = DismissibleToastAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
DismissibleToastAction::ClickDismissButton(uuid) => {
self.dismiss_toast_by_uuid(uuid, ctx);
}
DismissibleToastAction::ClickBody(uuid) => {
if let Some(index) = self.toasts.iter().position(|t| t.uuid == *uuid) {
let toast = self.toasts.remove(index);
if let Some(abort_handle) = toast.abort_handle {
abort_handle.abort();
}
if let Some(on_body_click) = &toast.dismissible_toast.on_body_click {
on_body_click(ctx);
}
ctx.notify();
}
}
}
}
}
/// The hyperlink in a toast.
#[derive(Clone)]
pub struct ToastLink<A: Action + Clone> {
text: String,
href: Option<String>,
action: Option<A>,
keystroke: Option<Keystroke>,
mouse_hover_state: MouseStateHandle,
}
impl<A: Action + Clone> ToastLink<A> {
pub fn new(text: String) -> Self {
Self {
text,
href: None,
action: None,
keystroke: None,
mouse_hover_state: Default::default(),
}
}
#[allow(dead_code)]
pub fn with_href(mut self, href: String) -> Self {
self.href = Some(href);
self
}
pub fn with_onclick_action(mut self, action: A) -> Self {
self.action = Some(action);
self
}
pub fn with_keystroke(mut self, keystroke: Keystroke) -> Self {
self.keystroke = Some(keystroke);
self
}
fn render(&self, ui_builder: &UiBuilder, font_color: ColorU) -> Box<dyn Element> {
let action = self.action.clone();
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
row.add_child(
ui_builder
.link(
self.text.clone(),
self.href.clone(),
Some(Box::new(move |ctx| {
if let Some(action) = &action {
ctx.dispatch_typed_action(action.clone());
}
})),
self.mouse_hover_state.clone(),
)
.soft_wrap(false)
.with_style(UiComponentStyles {
font_color: Some(font_color),
font_weight: Some(Weight::Bold),
..Default::default()
})
.soft_wrap(true)
.build()
.finish(),
);
if let Some(keystroke) = &self.keystroke {
row.add_child(
Container::new(ui_builder.keyboard_shortcut(keystroke).build().finish())
.with_margin_left(4.)
.finish(),
);
}
row.finish()
}
}
/// Callback type for body click actions.
/// Note: Rc is used to allow Clone on DismissibleToast.
pub type OnBodyClickCallback<A> = Rc<dyn Fn(&mut ViewContext<DismissibleToastStack<A>>)>;
/// Holds the data and logic needed to render an individual toast in the stack.
#[derive(Clone)]
pub struct DismissibleToast<A: Action + Clone> {
flavor: ToastFlavor,
main_text: String,
link: Option<ToastLink<A>>,
close_button_mouse_state: MouseStateHandle,
close_button_hover_state: MouseStateHandle,
/// An optional string-based ID representing the object that is the subject of this toast.
/// Future toasts added to the stack will auto-dismiss any toasts still in the stack with the
/// same ID, as it's likely the older ones are now out-of-date.
object_id: Option<String>,
action_button: Option<ViewHandle<ActionButton>>,
/// Optional callback invoked when the toast body is clicked.
pub(crate) on_body_click: Option<OnBodyClickCallback<A>>,
}
pub enum ToastType {
CloudObjectNotFound,
}
impl<A: Action + Clone> DismissibleToast<A> {
pub fn new(main_text: String, flavor: ToastFlavor) -> Self {
Self {
flavor,
main_text,
link: None,
close_button_mouse_state: Default::default(),
close_button_hover_state: Default::default(),
object_id: Default::default(),
action_button: Default::default(),
on_body_click: None,
}
}
pub fn default(main_text: String) -> Self {
Self::new(main_text, ToastFlavor::Default)
}
pub fn success(main_text: String) -> Self {
Self::new(main_text, ToastFlavor::Success)
}
pub fn error(main_text: String) -> Self {
Self::new(main_text, ToastFlavor::Error)
}
pub fn with_link(mut self, link: ToastLink<A>) -> Self {
self.link = Some(link);
self
}
pub fn with_object_id(mut self, object_id: String) -> Self {
self.object_id = Some(object_id);
self
}
/// Inserts an action button to the right of the toast.
pub fn with_action_button(mut self, button: ViewHandle<ActionButton>) -> Self {
self.action_button = Some(button);
self
}
/// Sets a callback to be invoked when the toast body is clicked.
/// When set, the entire toast body becomes clickable.
pub fn with_on_body_click<F>(mut self, callback: F) -> Self
where
F: Fn(&mut ViewContext<DismissibleToastStack<A>>) + 'static,
{
self.on_body_click = Some(Rc::new(callback));
self
}
fn is_clickable(&self) -> bool {
self.on_body_click.is_some()
}
fn position_id(&self, uuid: Uuid) -> String {
format!("toast_id_{uuid}")
}
fn render(&self, app: &AppContext, uuid: Uuid) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder();
let mut left_aligned = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
if let Some(icon) = self.render_icon(appearance.ui_font_size() * 1.2, appearance) {
left_aligned.add_child(icon);
}
left_aligned.add_child(
Shrinkable::new(
1.,
ui_builder
.wrappable_text(self.main_text.clone(), true)
.with_style(UiComponentStyles {
font_size: Some(appearance.ui_font_size() * 1.2),
font_color: Some(self.flavor.text_color(appearance)),
..Default::default()
})
.build()
.finish(),
)
.finish(),
);
let mut right_aligned = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_main_axis_size(MainAxisSize::Min);
if let Some(link) = &self.link {
right_aligned.add_child(
Container::new(
ConstrainedBox::new(
link.render(ui_builder, self.flavor.text_color(appearance)),
)
.with_max_width(TOAST_WIDTH / 3.)
.finish(),
)
.with_margin_left(TEXT_MARGIN)
.finish(),
);
}
if let Some(right_aligned_button) = &self.action_button {
right_aligned.add_child(
Container::new(
ConstrainedBox::new(ChildView::new(right_aligned_button).finish()).finish(),
)
.with_margin_left(TEXT_MARGIN)
.finish(),
);
}
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Min)
.with_child(Shrinkable::new(1., left_aligned.finish()).finish())
.with_child(Shrinkable::new(2., right_aligned.finish()).finish());
let is_clickable = self.is_clickable();
// On mobile devices, always show close button since hover effects don't work with touch
let is_mobile = warpui::platform::is_mobile_device();
Hoverable::new(self.close_button_hover_state.clone(), move |mouse_state| {
let toast_container = Container::new(row.finish())
.with_vertical_padding(VERTICAL_PADDING)
.with_horizontal_padding(HORIZONTAL_PADDING)
.with_background(self.flavor.bg_color(appearance))
.with_corner_radius(warpui::elements::CornerRadius::with_all(Radius::Pixels(
TOAST_CORNER_RADIUS,
)))
.with_border(Border::all(1.).with_border_fill(self.flavor.border_color(appearance)))
.finish();
let toast_element: Box<dyn Element> = if is_clickable {
EventHandler::new(toast_container)
.on_left_mouse_down(move |ctx, _, _| {
ctx.dispatch_typed_action(DismissibleToastAction::ClickBody(uuid));
DispatchEventResult::StopPropagation
})
.finish()
} else {
toast_container
};
let mut stack = Stack::new()
.with_child(SavePosition::new(toast_element, &self.position_id(uuid)).finish());
if mouse_state.is_hovered() || is_mobile {
stack.add_positioned_overlay_child(
self.render_close_button(ui_builder, uuid, appearance),
OffsetPositioning::offset_from_save_position_element(
self.position_id(uuid),
vec2f(4., -4.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::TopRight,
ChildAnchor::TopRight,
),
);
}
stack.finish()
})
.with_hover_out_delay(Duration::from_millis(500))
.finish()
}
fn render_icon(&self, icon_size: f32, appearance: &Appearance) -> Option<Box<dyn Element>> {
self.flavor.icon_path().map(|path| {
Container::new(
ConstrainedBox::new(Icon::new(path, self.flavor.text_color(appearance)).finish())
.with_max_height(icon_size)
.with_max_width(icon_size)
.finish(),
)
.with_margin_right(ICON_RIGHT_MARGIN)
.finish()
})
}
fn render_close_button(
&self,
ui_builder: &UiBuilder,
uuid: Uuid,
appearance: &Appearance,
) -> Box<dyn Element> {
Container::new(
ui_builder
.close_button(CLOSE_BUTTON_SIZE, self.close_button_mouse_state.clone())
.with_style(UiComponentStyles {
font_color: Some(appearance.theme().foreground().into()),
background: Some(ToastFlavor::Default.bg_color(appearance).into()),
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
border_width: Some(1.),
border_color: Some(ToastFlavor::Default.border_color(appearance).into()),
padding: Some(Coords {
top: 2.,
bottom: 2.,
left: 2.,
right: 2.,
}),
..Default::default()
})
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(DismissibleToastAction::ClickDismissButton(uuid))
})
.finish(),
)
.finish()
}
}
/// Represents the type of toast. Controls color and icon in order to communicate success, error,
/// etc.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ToastFlavor {
Default,
Success,
Error,
}
impl ToastFlavor {
fn icon_path(&self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::Success => Some(SUCCESS_ICON_PATH),
Self::Error => Some(ERROR_ICON_PATH),
}
}
fn text_color(&self, appearance: &Appearance) -> ColorU {
let theme = appearance.theme();
match self {
ToastFlavor::Default => theme.main_text_color(theme.background()).into(),
_ => theme.background().into(),
}
}
fn bg_color(&self, appearance: &Appearance) -> Fill {
let theme = appearance.theme();
match self {
Self::Default => internal_colors::neutral_4(theme).into(),
Self::Success => theme.ansi_fg_green().into(),
Self::Error => theme.ansi_fg_red().into(),
}
}
fn border_color(&self, appearance: &Appearance) -> Fill {
let theme = appearance.theme();
match self {
ToastFlavor::Default => internal_colors::neutral_3(theme).into(),
ToastFlavor::Success => theme.ansi_bg_green().into(),
ToastFlavor::Error => theme.ansi_bg_red().into(),
}
}
}
+572
View File
@@ -0,0 +1,572 @@
use std::fmt::Debug;
use pathfinder_color::ColorU;
use warpui::{
elements::{
Border, ChildAnchor, ChildView, ConstrainedBox, Container, Element, Icon,
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentElement,
PositionedElementAnchor, PositionedElementOffsetBounds, SavePosition, Stack,
},
fonts::FamilyId,
geometry::vector::vec2f,
scene::DropShadow,
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::{Coords, UiComponent, UiComponentStyles},
},
Action, AppContext, BlurContext, Entity, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle, WeakViewHandle,
};
use crate::{
appearance::Appearance,
menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields, MenuVariant},
};
pub const TOP_MENU_BAR_HEIGHT: f32 = 30.;
pub const TOP_MENU_BAR_MAX_WIDTH: f32 = 190.;
pub const DROPDOWN_PADDING: f32 = 6.;
pub type MenuHeaderTextFormatter = Box<dyn Fn(&str) -> String>;
#[derive(Clone, Default)]
pub enum DropdownStyle {
#[default]
Secondary,
/// No border, smaller text, smaller padding
#[allow(dead_code)]
Naked,
/// Similar to Secondary but with ActionButton-like hover behavior:
/// background fill on hover instead of border color change.
/// TODO this should probably replace the default `Secondary` theme
ActionButtonSecondary,
}
impl DropdownStyle {
fn ui_component_styles(&self) -> UiComponentStyles {
match self {
DropdownStyle::Secondary | DropdownStyle::ActionButtonSecondary => UiComponentStyles {
padding: Some(Coords {
top: 5.,
bottom: 5.,
left: 8.,
right: 8.,
}),
..Default::default()
},
DropdownStyle::Naked => UiComponentStyles {
..Default::default()
},
}
}
}
/// A dropdown menu view. The view renders each DropdownItem. When a menu item is clicked,
/// on_click_action_name is dispatched, with the value of the corresponding menu item.
pub struct Dropdown<A: Action + Clone> {
is_expanded: bool,
disabled: bool,
top_bar_mouse_state: MouseStateHandle,
top_bar_max_width: f32,
element_anchor: PositionedElementAnchor,
child_anchor: ChildAnchor,
main_axis_size: MainAxisSize,
dropdown: ViewHandle<Menu<DropdownAction<A>>>,
selected_item: Option<MenuItem<DropdownAction<A>>>,
// Function for overriding the default closed-state text (the selected item)
menu_header_text_override: Option<MenuHeaderTextFormatter>,
self_handle: WeakViewHandle<Self>,
style: DropdownStyle,
use_drop_shadow: bool,
font_color: Option<ColorU>,
font_size: Option<f32>,
padding: Option<Coords>,
vertical_margin: f32,
top_bar_height: f32,
}
#[derive(Clone)]
pub struct DropdownItem<A: Action + Clone> {
/// Text to display for the item
pub display_text: String,
/// Constructor for the typed action object
action: A,
/// Custom font for the dropdown item
family_id: Option<FamilyId>,
}
impl<A> DropdownItem<A>
where
A: Action + Clone,
{
pub fn new<S>(display_text: S, action: A) -> Self
where
S: Into<String>,
{
Self {
display_text: display_text.into(),
action,
family_id: None,
}
}
// Override the font of the drop down item. If this is not set, the default will
// be the ui_font_family.
pub fn with_font_override(mut self, family_id: FamilyId) -> Self {
self.family_id = Some(family_id);
self
}
}
impl<A> From<&DropdownItem<A>> for MenuItem<DropdownAction<A>>
where
A: Action + Clone,
{
fn from(dropdown_item: &DropdownItem<A>) -> MenuItem<DropdownAction<A>> {
let menu_item = MenuItemFields::new(dropdown_item.display_text.clone())
.with_on_select_action(DropdownAction::SelectActionAndClose(
dropdown_item.action.clone(),
));
if let Some(family_id) = dropdown_item.family_id {
menu_item.with_font_override(family_id).into_item()
} else {
menu_item.into_item()
}
}
}
impl<A> From<A> for DropdownAction<A>
where
A: Action + Clone,
{
fn from(action: A) -> DropdownAction<A> {
DropdownAction::SelectActionAndClose(action)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum DropdownAction<A: Action + Clone> {
Focus(usize),
Close,
SelectActionAndClose(A),
ToggleExpanded,
}
pub enum DropdownEvent {
ToggleExpanded,
Close,
}
impl<A> Dropdown<A>
where
A: Action + Clone,
{
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let dropdown = ctx.add_typed_action_view(|ctx| {
let theme = Appearance::as_ref(ctx).theme();
Menu::new()
.with_menu_variant(MenuVariant::scrollable())
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.prevent_interaction_with_other_elements()
});
ctx.subscribe_to_view(&dropdown, move |me, _, event, ctx| {
me.handle_menu_event(event, ctx);
});
Self {
main_axis_size: MainAxisSize::Max,
is_expanded: false,
disabled: false,
dropdown,
top_bar_mouse_state: Default::default(),
top_bar_max_width: TOP_MENU_BAR_MAX_WIDTH,
selected_item: None,
menu_header_text_override: None,
self_handle: ctx.handle(),
style: Default::default(),
element_anchor: PositionedElementAnchor::BottomLeft,
child_anchor: ChildAnchor::TopLeft,
use_drop_shadow: false,
font_color: None,
font_size: None,
padding: None,
vertical_margin: DROPDOWN_PADDING,
top_bar_height: TOP_MENU_BAR_HEIGHT,
}
}
pub fn with_drop_shadow(mut self) -> Self {
self.use_drop_shadow = true;
self
}
pub fn set_font_color(&mut self, color: ColorU, ctx: &mut ViewContext<Self>) {
self.font_color = Some(color);
ctx.notify();
}
pub fn set_font_size(&mut self, size: f32, ctx: &mut ViewContext<Self>) {
self.font_size = Some(size);
ctx.notify();
}
pub fn set_vertical_margin(&mut self, margin: f32, ctx: &mut ViewContext<Self>) {
self.vertical_margin = margin;
ctx.notify();
}
pub fn set_top_bar_height(&mut self, height: f32, ctx: &mut ViewContext<Self>) {
self.top_bar_height = height;
ctx.notify();
}
pub fn set_padding(&mut self, padding: Coords, ctx: &mut ViewContext<Self>) {
self.padding = Some(padding);
ctx.notify();
}
#[allow(dead_code)]
pub fn set_style(&mut self, style: DropdownStyle, ctx: &mut ViewContext<Self>) {
self.style = style;
ctx.notify();
}
/// Set the main_axis_size behavior for the dropdown header button.
///
/// Default is MainAxisSize::Max, set to MainAxisSize::Min if you want to wrap the dropdown to
/// the text that's filling it.
pub fn set_main_axis_size(
&mut self,
main_axis_size: MainAxisSize,
ctx: &mut ViewContext<Self>,
) {
self.main_axis_size = main_axis_size;
ctx.notify();
}
pub fn set_menu_header_text_override<F>(&mut self, formatter: F)
where
F: Fn(&str) -> String + 'static,
{
self.menu_header_text_override = Some(Box::new(formatter));
}
pub fn set_menu_position(
&mut self,
element_anchor: PositionedElementAnchor,
child_anchor: ChildAnchor,
ctx: &mut ViewContext<Self>,
) {
self.element_anchor = element_anchor;
self.child_anchor = child_anchor;
ctx.notify();
}
pub fn add_items(&mut self, items: Vec<DropdownItem<A>>, ctx: &mut ViewContext<Self>) {
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.add_items(items.iter().map(|item| item.into()));
ctx.notify();
});
ctx.notify();
}
pub fn is_focused(&self, ctx: &AppContext) -> bool {
let Some(handle) = self.self_handle.upgrade(ctx) else {
return false;
};
if handle.is_focused(ctx) {
return true;
}
if self.dropdown.is_focused(ctx) {
return true;
}
false
}
pub fn set_items(&mut self, items: Vec<DropdownItem<A>>, ctx: &mut ViewContext<Self>) {
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_items(items.iter().map(|item| item.into()), ctx);
});
ctx.notify();
}
// Most dropdowns don't need to use rich menu features like separators, indents, and submenus.
// But some do and, for those, we expose a "rich" item API.
pub fn set_rich_items(
&mut self,
items: impl IntoIterator<Item = MenuItem<DropdownAction<A>>>,
ctx: &mut ViewContext<Self>,
) {
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_items(items, ctx);
});
ctx.notify();
}
pub fn set_disabled(&mut self, ctx: &mut ViewContext<Self>) {
self.disabled = true;
ctx.notify();
}
pub fn set_enabled(&mut self, ctx: &mut ViewContext<Self>) {
self.disabled = false;
ctx.notify();
}
/// Select the item with the given name. If no such item exists, this clears the selection.
pub fn set_selected_by_name(
&mut self,
selected_item: impl AsRef<str>,
ctx: &mut ViewContext<Self>,
) {
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_name(selected_item, ctx);
ctx.notify();
});
self.selected_item = self.selected_item(ctx);
ctx.notify();
}
/// Select the item at the given index. If the index is out of bounds, this clears the selection.
pub fn set_selected_by_index(&mut self, selected_index: usize, ctx: &mut ViewContext<Self>) {
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(selected_index, ctx);
ctx.notify();
});
self.selected_item = self.selected_item(ctx);
ctx.notify();
}
/// Select the dropdown item whose on-select action equals the given action. If no such item exists,
/// this clears the selection.
///
/// This is primarily useful when items are dynamically generated and correspond to some backing data that's captured by the action.
pub fn set_selected_by_action(&mut self, action: A, ctx: &mut ViewContext<Self>)
where
A: PartialEq,
{
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_action(&DropdownAction::SelectActionAndClose(action), ctx);
ctx.notify();
});
self.selected_item = self.selected_item(ctx);
ctx.notify();
}
pub fn set_selected_to_none(&mut self, ctx: &mut ViewContext<Self>) {
self.selected_item = None;
ctx.notify();
}
pub fn set_top_bar_max_width(&mut self, max_width: f32) {
self.top_bar_max_width = max_width;
}
pub fn set_menu_width(&mut self, width: f32, ctx: &mut ViewContext<Self>) {
self.dropdown.update(ctx, |menu, ctx| {
menu.set_width(width);
ctx.notify();
})
}
pub fn set_menu_max_height(&mut self, height: f32, ctx: &mut ViewContext<Self>) {
self.dropdown.update(ctx, |menu, ctx| {
menu.set_height(height);
ctx.notify();
})
}
fn selected_item(&self, ctx: &mut ViewContext<Self>) -> Option<MenuItem<DropdownAction<A>>> {
self.dropdown
.read(ctx, |dropdown, _| dropdown.selected_item())
}
fn focus(&mut self, _delta: usize, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.dropdown);
ctx.notify();
}
fn select_action_and_close(&mut self, action: &A, ctx: &mut ViewContext<Self>) {
ctx.dispatch_typed_action(action);
self.close(ctx);
}
fn close(&mut self, ctx: &mut ViewContext<Self>) {
self.is_expanded = false;
ctx.emit(DropdownEvent::Close);
ctx.notify();
}
pub fn toggle_expanded(&mut self, ctx: &mut ViewContext<Self>) {
self.is_expanded = !self.is_expanded;
if self.is_expanded {
ctx.focus(&self.dropdown);
ctx.emit(DropdownEvent::ToggleExpanded);
}
ctx.notify();
}
fn render_top_bar(&self, appearance: &Appearance) -> Box<dyn Element> {
let icon_path = "bundled/svg/chevron-down.svg";
let (selected_item_text, font_family_id) = match self.selected_item.clone() {
Some(MenuItem::Item(fields)) => {
let label = fields.label();
let text = if let Some(formatter) = &self.menu_header_text_override {
formatter(label)
} else {
label.to_string()
};
(text, fields.override_font_family())
}
_ => (String::new(), None),
};
let mut top_bar = appearance
.ui_builder()
.button(
match self.style {
DropdownStyle::Secondary => ButtonVariant::Outlined,
DropdownStyle::Naked => ButtonVariant::Text,
DropdownStyle::ActionButtonSecondary => ButtonVariant::Secondary,
},
self.top_bar_mouse_state.clone(),
)
.with_text_and_icon_label(
TextAndIcon::new(
TextAndIconAlignment::TextFirst,
selected_item_text,
Icon::new(
icon_path,
self.font_color
.unwrap_or_else(|| appearance.theme().active_ui_text_color().into()),
),
self.main_axis_size,
MainAxisAlignment::SpaceBetween,
vec2f(15., 15.),
)
.with_inner_padding(match self.style {
DropdownStyle::Secondary | DropdownStyle::ActionButtonSecondary => 10.,
DropdownStyle::Naked => 6.,
}),
)
.with_style(self.style.ui_component_styles())
.with_style(UiComponentStyles {
font_color: self.font_color,
font_size: self.font_size,
padding: self.padding,
..Default::default()
})
.set_clicked_styles(None);
if self.disabled {
top_bar = top_bar.disabled();
}
if let Some(font_family_id) = font_family_id {
top_bar =
top_bar.with_style(UiComponentStyles::default().set_font_family_id(font_family_id))
}
let top_bar_element = top_bar.build().on_click(|ctx, _, _| {
ctx.dispatch_typed_action(DropdownAction::<A>::ToggleExpanded);
});
SavePosition::new(
Container::new(
ConstrainedBox::new(top_bar_element.finish())
.with_max_width(self.top_bar_max_width)
.with_height(self.top_bar_height)
.finish(),
)
.finish(),
&self.top_bar_label(),
)
.finish()
}
fn top_bar_label(&self) -> String {
format!("dropdown_top_bar_{}", self.dropdown.id())
}
fn handle_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
match event {
MenuEvent::Close { via_select_item: _ } => self.close(ctx),
MenuEvent::ItemSelected => {
self.selected_item = self.selected_item(ctx);
ctx.notify();
}
MenuEvent::ItemHovered => {}
}
}
}
impl<A> Entity for Dropdown<A>
where
A: Action + Clone,
{
type Event = DropdownEvent;
}
impl<A> TypedActionView for Dropdown<A>
where
A: Action + Clone,
{
type Action = DropdownAction<A>;
fn handle_action(&mut self, action: &DropdownAction<A>, ctx: &mut ViewContext<Self>) {
match action {
DropdownAction::Focus(delta) => self.focus(*delta, ctx),
DropdownAction::Close => self.close(ctx),
DropdownAction::SelectActionAndClose(action) => {
self.select_action_and_close(action, ctx)
}
DropdownAction::ToggleExpanded => self.toggle_expanded(ctx),
}
}
}
impl<A> View for Dropdown<A>
where
A: Action + Clone,
{
fn ui_name() -> &'static str {
"Dropdown"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut dropdown_stack = Stack::new().with_child(self.render_top_bar(appearance));
if self.is_expanded {
let mut menu = ChildView::new(&self.dropdown).finish();
if self.use_drop_shadow {
menu = Container::new(menu)
.with_drop_shadow(DropShadow::default())
.finish();
}
dropdown_stack.add_positioned_overlay_child(
menu,
OffsetPositioning::offset_from_save_position_element(
self.top_bar_label(),
vec2f(0., 0.),
PositionedElementOffsetBounds::WindowByPosition,
self.element_anchor,
self.child_anchor,
),
);
}
Container::new(dropdown_stack.finish())
.with_margin_top(self.vertical_margin)
.with_margin_bottom(self.vertical_margin)
.finish()
}
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
if blur_ctx.is_self_blurred() {
ctx.emit(DropdownEvent::Close);
}
}
}
+175
View File
@@ -0,0 +1,175 @@
use warpui::{
elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
MouseStateHandle, ParentElement, Radius, Text,
},
platform::Cursor,
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::{appearance::Appearance, ui_components::icons::Icon};
pub enum NewFeaturePopupLabel {
/// A static label.
FromString(String),
/// A label that is computed on demand.
FromCallable(Box<dyn Fn(&AppContext) -> String>),
}
pub enum FeaturePopupBadge {
// Displays "NEW" badge prior to the label
New,
// Displays an alert icon prior to the label
AlertIcon,
}
/// A dismissable popup that displays a label indicating that a new feature is available.
pub struct FeaturePopup {
dismiss_mouse_state: MouseStateHandle,
label: NewFeaturePopupLabel,
badge: FeaturePopupBadge,
}
#[derive(Debug, Clone)]
pub enum NewFeaturePopupAction {
Dismiss,
}
impl FeaturePopup {
pub fn new_feature(label: NewFeaturePopupLabel) -> Self {
Self {
dismiss_mouse_state: Default::default(),
label,
badge: FeaturePopupBadge::New,
}
}
pub fn alert_icon(label: NewFeaturePopupLabel) -> Self {
Self {
dismiss_mouse_state: Default::default(),
label,
badge: FeaturePopupBadge::AlertIcon,
}
}
fn render_badge(&self, appearance: &Appearance) -> Box<dyn Element> {
let background = appearance.theme().background();
match self.badge {
FeaturePopupBadge::New => Container::new(
Text::new(
"NEW",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(appearance.theme().main_text_color(background).into())
.finish(),
)
.with_vertical_padding(2.)
.with_horizontal_padding(4.)
.with_background_color(
appearance
.theme()
.ansi_bg(appearance.theme().terminal_colors().normal.green),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
.finish(),
FeaturePopupBadge::AlertIcon => Container::new(
ConstrainedBox::new(
Icon::AlertCircle
.to_warpui_icon(appearance.theme().main_text_color(
appearance.theme().terminal_colors().normal.green.into(),
))
.finish(),
)
.with_height(16.)
.with_width(16.)
.finish(),
)
.finish(),
}
}
}
impl View for FeaturePopup {
fn ui_name() -> &'static str {
"FeaturePopup"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let background = appearance.theme().background();
let new_badge = self.render_badge(appearance);
let label = match &self.label {
NewFeaturePopupLabel::FromString(label) => label.clone(),
NewFeaturePopupLabel::FromCallable(callable) => callable(app),
};
ConstrainedBox::new(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Container::new(new_badge).with_margin_right(4.).finish())
.with_child(
Container::new(
Text::new(
label,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(background.into())
.finish(),
)
.with_horizontal_padding(4.)
.finish(),
)
.with_child(
Hoverable::new(self.dismiss_mouse_state.clone(), |_| {
ConstrainedBox::new(
Icon::X
.to_warpui_icon(appearance.theme().sub_text_color(
appearance.theme().main_text_color(background),
))
.finish(),
)
.with_height(16.)
.with_width(16.)
.finish()
})
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(NewFeaturePopupAction::Dismiss)
})
.with_cursor(Cursor::PointingHand)
.finish(),
)
.finish(),
)
.with_horizontal_padding(4.)
.with_vertical_padding(4.)
.with_background(appearance.theme().main_text_color(background))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish(),
)
.finish()
}
}
impl TypedActionView for FeaturePopup {
type Action = NewFeaturePopupAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
NewFeaturePopupAction::Dismiss => {
ctx.emit(NewFeaturePopupEvent::Dismissed);
}
}
}
}
#[derive(Debug, Clone)]
pub enum NewFeaturePopupEvent {
Dismissed,
}
impl Entity for FeaturePopup {
type Event = NewFeaturePopupEvent;
}
@@ -0,0 +1,747 @@
use super::dropdown::{
DropdownAction, DropdownItem, MenuHeaderTextFormatter, DROPDOWN_PADDING, TOP_MENU_BAR_HEIGHT,
TOP_MENU_BAR_MAX_WIDTH,
};
use crate::{
appearance::Appearance,
editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
},
menu::{Event as MenuEvent, Menu, MenuItem, MenuVariant},
ui_components::icons,
};
use warp_editor::editor::NavigationKey;
use warpui::{
elements::{
Align, Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Dismiss, Element, EventHandler, Flex, MainAxisAlignment, MainAxisSize,
MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor,
PositionedElementOffsetBounds, Radius, SavePosition, Shrinkable, Stack,
},
geometry::vector::vec2f,
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::{Coords, UiComponent, UiComponentStyles},
},
Action, AppContext, BlurContext, Entity, FocusContext, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
const EMPTY_DROPDOWN_HEIGHT: f32 = 50.0;
pub enum FilterableDropdownEvent {
ToggleExpanded,
Close,
}
#[derive(Default, Debug, PartialEq)]
pub enum FilterableDropdownOrientation {
Up,
#[default]
Down,
}
pub struct FilterableDropdown<A: Action + Clone> {
is_expanded: bool,
disabled: bool,
top_bar_mouse_state: MouseStateHandle,
top_bar_max_width: f32,
main_axis_size: MainAxisSize,
dropdown: ViewHandle<Menu<DropdownAction<A>>>,
filter_editor: ViewHandle<EditorView>,
selected_item: Option<MenuItem<DropdownAction<A>>>,
items: Vec<DropdownItem<A>>,
orientation: FilterableDropdownOrientation,
static_menu_header: Option<&'static str>,
button_variant: ButtonVariant,
style_override: Option<UiComponentStyles>,
hovered_style_override: Option<UiComponentStyles>,
menu_header_text_override: Option<MenuHeaderTextFormatter>,
/// True when a pinned footer has been registered via `set_footer`.
/// When true, the footer lives inside the `Menu`'s own `Dismiss` (via
/// `Menu::set_pinned_footer_builder`), so clicks on it never trigger the
/// dismiss handler. The `FilterableDropdown` render also skips the
/// empty-state placeholder and always renders the `ChildView<Menu>` so
/// the footer remains visible even when the item list is empty.
has_pinned_footer: bool,
menu_width: Option<f32>,
vertical_margin: f32,
top_bar_height: f32,
}
impl<A> FilterableDropdown<A>
where
A: Action + Clone,
{
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let theme = Appearance::as_ref(ctx).theme();
let border = Border::all(1.).with_border_fill(theme.outline());
let dropdown = ctx.add_typed_action_view(|_ctx| {
Menu::new()
.with_menu_variant(MenuVariant::scrollable())
.with_border(border)
.prevent_interaction_with_other_elements()
});
ctx.subscribe_to_view(&dropdown, move |me, _, event, ctx| {
me.handle_menu_event(event, ctx);
});
let filter_editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let mut editor = EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions::ui_text(Some(appearance.ui_font_size()), appearance),
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
},
ctx,
);
editor.set_placeholder_text("Search", ctx);
editor
});
ctx.subscribe_to_view(&filter_editor, |me, _, event, ctx| {
me.handle_filter_editor_event(event, ctx);
});
FilterableDropdown {
is_expanded: false,
disabled: false,
dropdown,
filter_editor,
top_bar_mouse_state: Default::default(),
top_bar_max_width: TOP_MENU_BAR_MAX_WIDTH,
main_axis_size: MainAxisSize::Max,
selected_item: None,
items: Default::default(),
orientation: Default::default(),
static_menu_header: None,
button_variant: ButtonVariant::Outlined,
style_override: None,
hovered_style_override: None,
menu_header_text_override: None,
has_pinned_footer: false,
menu_width: None,
vertical_margin: DROPDOWN_PADDING,
top_bar_height: TOP_MENU_BAR_HEIGHT,
}
}
pub fn set_menu_header_text_override<F>(&mut self, formatter: F)
where
F: Fn(&str) -> String + 'static,
{
self.menu_header_text_override = Some(Box::new(formatter));
}
pub fn set_footer<F>(&mut self, builder: F, ctx: &mut ViewContext<Self>)
where
F: Fn(&AppContext) -> Box<dyn Element> + 'static,
{
self.has_pinned_footer = true;
// Pass the builder into the inner Menu so it is rendered inside the Dismiss.
// This way, clicks on the footer do not trigger the dismiss handler, allowing
// standard `on_click` (LeftMouseUp) behaviour with no timing issues.
self.dropdown.update(ctx, |menu, _| {
menu.set_pinned_footer_builder(builder);
});
}
pub fn clear_footer(&mut self, ctx: &mut ViewContext<Self>) {
self.has_pinned_footer = false;
self.dropdown.update(ctx, |menu, _| {
menu.clear_pinned_footer_builder();
});
}
/// Set the main_axis_size behavior for the dropdown header button.
///
/// Default is MainAxisSize::Max, set to MainAxisSize::Min if you want to wrap the dropdown to
/// the text that's filling it.
pub fn set_main_axis_size(
&mut self,
main_axis_size: MainAxisSize,
ctx: &mut ViewContext<Self>,
) {
self.main_axis_size = main_axis_size;
ctx.notify();
}
pub fn set_style(&mut self, style: UiComponentStyles) {
self.style_override = Some(style);
}
pub fn set_button_variant(&mut self, button_variant: ButtonVariant) {
self.button_variant = button_variant;
}
pub fn set_orientation(&mut self, orientation: FilterableDropdownOrientation) {
self.orientation = orientation;
}
pub fn add_items(&mut self, items: Vec<DropdownItem<A>>, ctx: &mut ViewContext<Self>) {
self.items.extend(items.iter().cloned());
self.set_filtered_items(ctx);
}
pub fn set_items(&mut self, items: Vec<DropdownItem<A>>, ctx: &mut ViewContext<Self>) {
self.items = items;
self.set_filtered_items(ctx);
// set_filtered_items intentionally preserves self.selected_item when
// the selected label is hidden by a filter query (so re-expanding the
// filter brings it back). However, set_items fully *replaces* the
// list, so a cached selection whose label no longer appears in the new
// items is stale and must be cleared — otherwise the top bar shows a
// ghost label and selected_item_label() returns a value that doesn't
// correspond to any actual item.
let label = self.current_selected_item_label();
if !label.is_empty() && !self.items.iter().any(|item| item.display_text == label) {
self.selected_item = None;
ctx.notify();
}
}
/// Set items from rich menu items (MenuItem). This passes the rich menu items to the
/// internal dropdown but also extracts searchable DropdownItem objects for filtering.
pub fn set_rich_items(
&mut self,
items: Vec<MenuItem<DropdownAction<A>>>,
ctx: &mut ViewContext<Self>,
) {
// Extract simple DropdownItem objects from MenuItem for filtering
self.items = items
.iter()
.filter_map(|item| match item {
MenuItem::Item(fields) => {
let label = fields.label().to_string();
fields.on_select_action().and_then(|action| {
if let DropdownAction::SelectActionAndClose(a) = action {
Some(DropdownItem::new(label, a.clone()))
} else {
None
}
})
}
_ => None, // Skip headers and separators
})
.collect();
// Set the full rich items on the internal dropdown
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_items(items, ctx);
});
ctx.notify();
}
/// The number of items in the dropdown.
pub fn len(&self) -> usize {
self.items.len()
}
#[expect(dead_code)]
pub fn reset_selection(&mut self, ctx: &mut ViewContext<Self>) {
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.reset_selection(ctx);
ctx.notify();
});
}
/// Select the item with the given name. If no such item exists, this clears the selection.
pub fn set_selected_by_name(
&mut self,
selected_item: impl AsRef<str>,
ctx: &mut ViewContext<Self>,
) {
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_name(selected_item.as_ref(), ctx);
ctx.notify();
});
// If the selected item has been filtered out, we don't want to clear
// the cached selected item. In all other cases, we overrite the cached
// selected item with the currently selected item in the dropdown.
let selected_item_in_dropdown = self.selected_item_in_dropdown(ctx);
if selected_item_in_dropdown.is_some()
|| self.current_selected_item_label() != selected_item.as_ref()
{
self.selected_item = selected_item_in_dropdown;
}
ctx.notify();
}
/// Select the item at the given index. If the index is out of bounds, this clears the selection.
pub fn set_selected_by_index(&mut self, selected_index: usize, ctx: &mut ViewContext<Self>) {
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(selected_index, ctx);
ctx.notify();
});
self.selected_item = self.selected_item_in_dropdown(ctx);
ctx.notify();
}
/// Select the dropdown item whose on-select action equals the given action. If no such item exists,
/// this clears the selection.
///
/// This is primarily useful when items are dynamically generated and correspond to some backing data that's captured by the action.
pub fn set_selected_by_action(&mut self, action: A, ctx: &mut ViewContext<Self>)
where
A: PartialEq,
{
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_action(&DropdownAction::SelectActionAndClose(action), ctx);
});
self.selected_item = self.selected_item_in_dropdown(ctx);
ctx.notify();
}
pub fn set_top_bar_max_width(&mut self, max_width: f32) {
self.top_bar_max_width = max_width;
}
pub fn set_menu_width(&mut self, width: f32, ctx: &mut ViewContext<Self>) {
self.menu_width = Some(width);
self.dropdown.update(ctx, |menu, ctx| {
menu.set_width(width);
ctx.notify();
})
}
pub fn set_disabled(&mut self, ctx: &mut ViewContext<Self>) {
self.disabled = true;
ctx.notify();
}
pub fn set_enabled(&mut self, ctx: &mut ViewContext<Self>) {
self.disabled = false;
ctx.notify();
}
fn selected_item_in_dropdown(
&self,
ctx: &mut ViewContext<Self>,
) -> Option<MenuItem<DropdownAction<A>>> {
self.dropdown
.read(ctx, |dropdown, _| dropdown.selected_item())
}
fn current_selected_item_label(&self) -> &str {
if let Some(MenuItem::Item(fields)) = self.selected_item.as_ref() {
fields.label()
} else {
""
}
}
pub fn selected_item_label(&self) -> Option<String> {
match self.selected_item.as_ref() {
Some(MenuItem::Item(fields)) => Some(fields.label().to_string()),
_ => None,
}
}
fn focus(&mut self, _delta: usize, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.filter_editor);
ctx.notify();
}
/// Dispatches the item's action up the responder chain and then closes the
/// dropdown.
///
/// The dispatch is synchronous, so any parent `TypedActionView::handle_action`
/// that receives `action` runs while this `FilterableDropdown` is mid-update
/// (its view has been removed from `window.views` by the caller).
/// Parent handlers **must not** call `self.dropdown.update(ctx, ...)` on this
/// dropdown from their `Select`-equivalent branch, or `update_view` will
/// panic with "Circular view update". The dropdown is already closed here
/// after the dispatch returns, so parents don't need to close it themselves.
fn select_action_and_close(&mut self, action: &A, ctx: &mut ViewContext<Self>) {
// Check against the length of the dropdown to no-op in the case
// there aren't any elements being rendered
if self.dropdown_items_len(ctx) > 0 {
ctx.dispatch_typed_action(action);
} else {
self.selected_item = None;
}
self.close(ctx);
}
pub fn close(&mut self, ctx: &mut ViewContext<Self>) {
self.is_expanded = false;
ctx.emit(FilterableDropdownEvent::Close);
ctx.notify();
}
pub(crate) fn toggle_expanded(&mut self, ctx: &mut ViewContext<Self>) {
self.is_expanded = !self.is_expanded;
if self.is_expanded {
ctx.focus(&self.filter_editor);
ctx.emit(FilterableDropdownEvent::ToggleExpanded);
}
ctx.notify();
}
pub(crate) fn is_expanded(&self) -> bool {
self.is_expanded
}
fn render_closed_top_bar(&self, appearance: &Appearance) -> Box<dyn Element> {
let (selected_item_text, font_family_id) = match self.static_menu_header {
Some(header) => (header.to_string(), None),
None => match self.selected_item.clone() {
Some(MenuItem::Item(fields)) => {
let label = fields.label();
let text = if let Some(formatter) = &self.menu_header_text_override {
formatter(label)
} else {
label.to_string()
};
(text, fields.override_font_family())
}
_ => (String::new(), None),
},
};
let mut top_bar = appearance
.ui_builder()
.button(self.button_variant, self.top_bar_mouse_state.clone())
.with_text_and_icon_label(
TextAndIcon::new(
TextAndIconAlignment::TextFirst,
selected_item_text,
icons::Icon::ChevronDown
.to_warpui_icon(appearance.theme().active_ui_text_color()),
self.main_axis_size,
MainAxisAlignment::SpaceBetween,
vec2f(15., 15.),
)
.with_inner_padding(10.),
)
.with_style(self.style_override.unwrap_or(UiComponentStyles {
padding: Some(Coords {
top: 5.,
bottom: 5.,
left: 8.,
right: 8.,
}),
..Default::default()
}))
.set_clicked_styles(None);
if let Some(hovered_style) = self.hovered_style_override {
top_bar = top_bar.with_hovered_styles(hovered_style);
}
if self.disabled {
top_bar = top_bar.disabled();
}
if let Some(font_family_id) = font_family_id {
top_bar =
top_bar.with_style(UiComponentStyles::default().set_font_family_id(font_family_id))
}
top_bar
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(DropdownAction::<A>::ToggleExpanded);
})
.finish()
}
fn render_filter_input(&self, appearance: &Appearance) -> Box<dyn Element> {
let h_padding = self
.style_override
.and_then(|s| s.padding)
.map(|p| (p.left, p.right))
.unwrap_or((8., 8.));
let search_icon = ConstrainedBox::new(
icons::Icon::SearchSmall
.to_warpui_icon(appearance.theme().active_ui_text_color())
.finish(),
)
.with_width(12.)
.with_height(12.)
.finish();
let filter_editor =
Container::new(Clipped::new(ChildView::new(&self.filter_editor).finish()).finish())
.with_margin_left(4.)
.finish();
let filter_bar = Flex::row()
.with_child(search_icon)
.with_child(Shrinkable::new(1., filter_editor).finish())
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.finish();
let centered_content = Flex::column()
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(filter_bar)
.finish();
Container::new(centered_content)
.with_padding_left(h_padding.0)
.with_padding_right(h_padding.1)
.with_border(
Border::all(1.).with_border_fill(appearance.theme().foreground().with_opacity(20)),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish()
}
fn render_top_bar(&self, appearance: &Appearance) -> Box<dyn Element> {
let top_bar_element = if self.is_expanded {
self.render_filter_input(appearance)
} else {
self.render_closed_top_bar(appearance)
};
SavePosition::new(
Container::new(
ConstrainedBox::new(top_bar_element)
.with_max_width(self.top_bar_max_width)
.with_height(self.top_bar_height)
.finish(),
)
.finish(),
&self.top_bar_label(),
)
.finish()
}
fn render_empty_menu(&self, appearance: &Appearance) -> Box<dyn Element> {
let background_fill = appearance.theme().surface_2();
let empty_text = appearance
.ui_builder()
.span("No matches found.")
.with_style(UiComponentStyles {
font_color: Some(appearance.theme().sub_text_color(background_fill).into()),
..Default::default()
})
.build()
.finish();
let empty_menu = ConstrainedBox::new(
Container::new(Align::new(empty_text).finish())
.with_background(background_fill)
.with_border(
Border::all(1.)
.with_border_fill(appearance.theme().foreground().with_opacity(20)),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish(),
)
.with_max_width(self.top_bar_max_width)
.with_height(EMPTY_DROPDOWN_HEIGHT)
.finish();
// Wrap with Dismiss to handle clicks outside the empty menu
Dismiss::new(EventHandler::new(empty_menu).finish())
.on_dismiss(|ctx, _app| {
ctx.dispatch_typed_action(DropdownAction::<A>::Close);
})
.prevent_interaction_with_other_elements()
.finish()
}
fn top_bar_label(&self) -> String {
format!("dropdown_top_bar_{}", self.dropdown.id())
}
fn handle_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
match event {
MenuEvent::Close { via_select_item: _ } => self.close(ctx),
MenuEvent::ItemSelected => {
self.selected_item = self.selected_item_in_dropdown(ctx);
ctx.notify();
}
MenuEvent::ItemHovered => {}
}
}
fn handle_filter_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Edited(_) => self.set_filtered_items(ctx),
EditorEvent::Escape => self.close(ctx),
EditorEvent::Enter => {
let selected_action = match self.selected_item.as_ref() {
Some(MenuItem::Item(fields)) => Some(fields.on_select_action().cloned()),
_ => None,
};
if let Some(Some(action)) = selected_action {
self.handle_action(&action, ctx);
}
ctx.notify();
}
EditorEvent::Navigate(NavigationKey::Up) => {
if self.dropdown_items_len(ctx) == 0 {
return;
}
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.select_previous(ctx);
});
self.selected_item = self.selected_item_in_dropdown(ctx);
ctx.notify();
}
EditorEvent::Navigate(NavigationKey::Down) => {
if self.dropdown_items_len(ctx) == 0 {
return;
}
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.select_next(ctx);
});
self.selected_item = self.selected_item_in_dropdown(ctx);
ctx.notify();
}
_ => (),
}
}
fn filter_query(&self, ctx: &AppContext) -> String {
self.filter_editor.as_ref(ctx).buffer_text(ctx)
}
fn set_filtered_items(&mut self, ctx: &mut ViewContext<Self>) {
let filter_query = self.filter_query(ctx).to_lowercase();
// We keep track of the label of the current element, and assume
// it won't be visible in the newly computed list of filtered items.
// If it isn't, we set the selected element to the first index of
// the new elements such that there's always a candidate element to select.
let current_label = self.current_selected_item_label();
let mut current_label_not_visible = true;
self.dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_items(
self.items
.iter()
.filter(|item| {
let item_matches_filter =
item.display_text.to_lowercase().contains(&filter_query);
if item.display_text == current_label && item_matches_filter {
current_label_not_visible = false;
};
item_matches_filter
})
.map(|item| item.into()),
ctx,
);
if current_label_not_visible && !dropdown.is_empty() {
dropdown.set_selected_by_index(0, ctx);
} else {
dropdown.set_selected_by_name(current_label, ctx);
}
ctx.notify();
});
ctx.notify();
}
fn dropdown_items_len(&self, ctx: &AppContext) -> usize {
self.dropdown.as_ref(ctx).items_len()
}
pub fn clear_filter(&mut self, ctx: &mut ViewContext<Self>) {
self.filter_editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
ctx.notify();
});
}
pub fn set_menu_header_to_static(&mut self, header: &'static str) {
self.static_menu_header = Some(header);
}
}
impl<A> Entity for FilterableDropdown<A>
where
A: Action + Clone,
{
type Event = FilterableDropdownEvent;
}
impl<A> TypedActionView for FilterableDropdown<A>
where
A: Action + Clone,
{
type Action = DropdownAction<A>;
fn handle_action(&mut self, action: &DropdownAction<A>, ctx: &mut ViewContext<Self>) {
match action {
DropdownAction::Focus(delta) => self.focus(*delta, ctx),
DropdownAction::Close => self.close(ctx),
DropdownAction::SelectActionAndClose(action) => {
self.select_action_and_close(action, ctx)
}
DropdownAction::ToggleExpanded => self.toggle_expanded(ctx),
}
}
}
impl<A> View for FilterableDropdown<A>
where
A: Action + Clone,
{
fn ui_name() -> &'static str {
"FilterableDropdown"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
self.focus(0, ctx)
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
// When a pinned footer is registered, always render the Menu ChildView even
// when the item list is empty, so the footer remains visible. The footer lives
// inside the Menu's Dismiss (via set_pinned_footer_builder), so clicks on it
// correctly do not trigger the dismiss handler.
let dropdown_menu = if !self.has_pinned_footer && self.dropdown_items_len(app) == 0 {
self.render_empty_menu(appearance)
} else {
ChildView::new(&self.dropdown).finish()
};
let mut dropdown_stack = Stack::new().with_child(self.render_top_bar(appearance));
if self.is_expanded {
dropdown_stack.add_positioned_overlay_child(
dropdown_menu,
if self.orientation == FilterableDropdownOrientation::Down {
OffsetPositioning::offset_from_save_position_element(
self.top_bar_label(),
vec2f(0., 0.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
)
} else {
OffsetPositioning::offset_from_save_position_element(
self.top_bar_label(),
vec2f(0., 0.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::TopLeft,
ChildAnchor::BottomLeft,
)
},
);
}
Container::new(dropdown_stack.finish())
.with_margin_top(self.vertical_margin)
.with_margin_bottom(self.vertical_margin)
.finish()
}
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
if blur_ctx.is_self_blurred() {
ctx.emit(FilterableDropdownEvent::Close);
}
}
}
+700
View File
@@ -0,0 +1,700 @@
use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
};
use crate::send_telemetry_from_ctx;
use crate::server::telemetry::{FindOption, TelemetryEvent};
use crate::settings::InputModeSettings;
use crate::ui_components::{blended_colors, icons::Icon};
use serde::Serialize;
use crate::themes::theme::Fill;
use pathfinder_color::ColorU;
use warpui::elements::{ChildAnchor, OffsetPositioning, Radius, SavePosition, Shrinkable};
use warpui::keymap::EditableBinding;
use warpui::ui_components::components::UiComponent;
pub use warpui::{
accessibility::{AccessibilityContent, WarpA11yRole},
elements::{ParentElement as _, Stack},
geometry::vector::vec2f,
AppContext,
};
use warpui::{
elements::{
Align, Border, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DropShadow, Element, Flex, Hoverable, MouseStateHandle, ParentAnchor, ParentOffsetBounds,
Text,
},
Entity, SingletonEntity, TypedActionView, View,
};
use warpui::{presenter::ChildView, ViewContext, ViewHandle};
use warpui::{FocusContext, ModelHandle};
pub const FIND_BAR_WIDTH: f32 = 500.;
const ICON_PADDING: f32 = 4.;
const HORIZONTAL_ICON_SPACING: f32 = 4.;
const ICON_CONTAINER_CORNER_RADIUS: f32 = 4.;
pub const FIND_BAR_PADDING: f32 = 4.;
const FIND_EDITOR_PADDING: f32 = 6.;
pub const FIND_EDITOR_BORDER_RADIUS: f32 = 6.;
pub(crate) const FIND_EDITOR_BORDER_WIDTH: f32 = 1.;
const FIND_EDITOR_FONT_SIZE: f32 = 12.;
pub const REGEX_TOGGLE_LABEL: &str = ". *";
pub const REGEX_TOGGLE_TOOLTIP: &str = "Regex toggle";
pub const CASE_SENSITIVE_LABEL: &str = "Aa";
pub const CASE_SENSITIVE_TOOLTIP: &str = "Case sensitive search";
pub const FIND_WITHIN_BLOCK_TOOLTIP: &str = "Find in selected block";
pub const FIND_PLACEHOLDER_TEXT: &str = "Find";
// Moving FindEvent, FindModel implementations away from terminal/.
pub enum FindEvent {
/// Emitted a find run has been executed.
RanFind,
/// Emitted when the focused match in the active find run has been updated.
UpdatedFocusedMatch,
}
pub trait FindModel {
fn focused_match_index(&self) -> Option<usize>;
fn match_count(&self) -> usize;
fn default_find_direction(&self, app: &AppContext) -> FindDirection;
fn alt_find_direction(&self, app: &AppContext) -> FindDirection {
match self.default_find_direction(app) {
FindDirection::Up => FindDirection::Down,
FindDirection::Down => FindDirection::Up,
}
}
}
pub enum Event {
CloseFindBar,
Update { query: Option<String> },
NextMatch { direction: FindDirection },
ToggleFindInBlock { value: bool },
ToggleCaseSensitivity { is_case_sensitive: bool },
ToggleRegexSearch { is_regex_enabled: bool },
}
#[derive(Debug, Copy, Clone, PartialEq, Serialize)]
pub enum FindDirection {
Up,
Down,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum FindWithinBlockState {
Enabled,
Disabled,
Hidden,
}
#[derive(Default)]
struct ButtonMouseStates {
match_up: MouseStateHandle,
match_down: MouseStateHandle,
close: MouseStateHandle,
toggle_find_in_block: MouseStateHandle,
toggle_case_sensitivity: MouseStateHandle,
toggle_regex_search: MouseStateHandle,
}
pub struct Find<T: FindModel + Entity<Event = FindEvent> + 'static> {
editor: ViewHandle<EditorView>,
model: ModelHandle<T>,
button_mouse_states: ButtonMouseStates,
pub case_sensitivity_enabled: bool,
pub regex_search_enabled: bool,
pub display_find_within_block: FindWithinBlockState,
}
#[derive(Copy, Clone, Debug)]
pub enum FindAction {
Up,
Down,
Close,
ToggleFindInBlock,
ToggleCaseSensitivity,
ToggleRegexSearch,
CmdG,
CmdShiftG,
}
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_editable_bindings([
EditableBinding::new(
"find:find_next_occurrence",
"Find the next occurrence of your search query",
FindAction::CmdG,
)
.with_context_predicate(id!("Find"))
// Both Intellij and VSCode use f3/shift-f3 to navigate find occurrences on windows / linux.
// See https://www.jetbrains.com/help/idea/reference-keymap-win-default.html#find_everything.
.with_mac_key_binding("cmd-g")
.with_linux_or_windows_key_binding("f3"),
EditableBinding::new(
"find:find_prev_occurrence",
"Find the previous occurrence of your search query",
FindAction::CmdShiftG,
)
.with_context_predicate(id!("Find"))
.with_mac_key_binding("cmd-shift-G")
.with_linux_or_windows_key_binding("shift-f3"),
])
}
impl<T: FindModel + Entity<Event = FindEvent> + 'static> Find<T> {
pub fn new(model: ModelHandle<T>, ctx: &mut ViewContext<Self>) -> Self {
let editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let mut editor = EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions::ui_text(Some(FIND_EDITOR_FONT_SIZE), appearance),
select_all_on_focus: true,
clear_selections_on_blur: true,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
},
ctx,
);
editor.set_placeholder_text(FIND_PLACEHOLDER_TEXT, ctx);
editor
});
ctx.subscribe_to_view(&editor, move |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
ctx.subscribe_to_model(&InputModeSettings::handle(ctx), |_, _, _, ctx| {
ctx.notify();
});
ctx.subscribe_to_model(&model, |_, _, event, ctx| match event {
FindEvent::RanFind | FindEvent::UpdatedFocusedMatch => ctx.notify(),
});
Self {
editor,
model,
button_mouse_states: Default::default(),
case_sensitivity_enabled: false,
regex_search_enabled: false,
display_find_within_block: FindWithinBlockState::Disabled,
}
}
pub fn editor(&self) -> &ViewHandle<EditorView> {
&self.editor
}
pub fn is_editor_focused(&self, ctx: &AppContext) -> bool {
self.editor.as_ref(ctx).is_focused()
}
fn editor_text(&self, ctx: &AppContext) -> String {
self.editor.as_ref(ctx).buffer_text(ctx)
}
pub fn set_query_text(&mut self, text: &str, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |editor, ctx| {
editor.select_all(ctx);
editor.insert_selected_text(text, ctx);
});
}
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Edited(_) => {
let query = self.editor_text(ctx);
ctx.emit(Event::Update {
// If the query is empty, don't search for an empty string - set the query to
// `None`.
query: (!query.is_empty()).then_some(query),
});
self.emit_result_a11y_content(ctx);
ctx.notify();
}
EditorEvent::Enter => {
self.focus_next_match(T::default_find_direction(self.model.as_ref(ctx), ctx), ctx);
}
EditorEvent::ShiftEnter | EditorEvent::AltEnter => {
self.focus_next_match(T::alt_find_direction(self.model.as_ref(ctx), ctx), ctx);
}
EditorEvent::Escape => {
self.close_find_bar(ctx);
}
// If the user is focusing on the editor in the find bar, we
// want to keep the current selected block
EditorEvent::ClearParentSelections => {}
_ => {}
}
}
fn focus_next_match(&mut self, direction: FindDirection, ctx: &mut ViewContext<Self>) {
// All of the acutal update logic for the selected match goes through the event codepath
// but for some reason the logic for updating the match index happens below.
ctx.emit(Event::NextMatch { direction });
self.emit_result_a11y_content(ctx);
ctx.notify();
}
/// Emits the a11y announcement informing about the current match/result.
/// Note that it's done outside of the regular action_accessibility_contents flow,
/// as `focus_next_match` may have multiple entrypoints (that are not Action).
pub fn emit_result_a11y_content(&mut self, ctx: &mut ViewContext<Self>) {
let content = if let Some(match_index) = self.model.as_ref(ctx).focused_match_index() {
AccessibilityContent::new(
format!(
"Result {} of {}.",
match_index + 1,
self.model.as_ref(ctx).match_count()
),
"Use enter and shift-enter to navigate between matches. Escape to quit.",
WarpA11yRole::UserAction,
)
} else {
AccessibilityContent::new_without_help("No results.", WarpA11yRole::UserAction)
};
ctx.emit_a11y_content(content);
}
fn close_find_bar(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(Event::CloseFindBar);
}
fn toggle_find_within_block(&mut self, ctx: &mut ViewContext<Self>) {
self.display_find_within_block = match self.display_find_within_block {
FindWithinBlockState::Enabled => FindWithinBlockState::Disabled,
FindWithinBlockState::Disabled => FindWithinBlockState::Enabled,
_ => return,
};
send_telemetry_from_ctx!(
TelemetryEvent::ToggleFindOption {
option: FindOption::FindInBlock,
enabled: self.display_find_within_block == FindWithinBlockState::Enabled,
},
ctx
);
ctx.emit(Event::ToggleFindInBlock {
value: self.display_find_within_block == FindWithinBlockState::Enabled,
});
}
fn toggle_case_sensitivity(&mut self, ctx: &mut ViewContext<Self>) {
self.case_sensitivity_enabled = !self.case_sensitivity_enabled;
send_telemetry_from_ctx!(
TelemetryEvent::ToggleFindOption {
option: FindOption::CaseSensitive,
enabled: self.case_sensitivity_enabled
},
ctx
);
ctx.emit(Event::ToggleCaseSensitivity {
is_case_sensitive: self.case_sensitivity_enabled,
});
}
fn toggle_regex_search(&mut self, ctx: &mut ViewContext<Self>) {
self.regex_search_enabled = !self.regex_search_enabled;
send_telemetry_from_ctx!(
TelemetryEvent::ToggleFindOption {
option: FindOption::Regex,
enabled: self.regex_search_enabled
},
ctx
);
ctx.emit(Event::ToggleRegexSearch {
is_regex_enabled: self.regex_search_enabled,
});
}
fn render_match_index(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
// If there is some match index, we add 1 to it since the UI is 1-indexed
// (i.e. first match starts at index 1 out of the total number of matches).
let index = match self.model.as_ref(app).focused_match_index() {
None => 0,
Some(idx) => idx + 1,
};
let label = format!("{}/{}", index, self.model.as_ref(app).match_count());
Text::new_inline(label, appearance.ui_font_family(), FIND_EDITOR_FONT_SIZE)
.with_color(blended_colors::text_sub(
appearance.theme(),
appearance.theme().surface_1(),
))
.finish()
}
#[allow(clippy::too_many_arguments)]
fn render_hoverable_icon_in_editor(
&self,
appearance: &Appearance,
icon: Icon,
is_selected: bool,
mouse_state_handle: MouseStateHandle,
on_click_action: FindAction,
size: f32,
tooltip_text: Option<&str>,
right_margin: f32,
) -> Box<dyn Element> {
Hoverable::new(mouse_state_handle, |state| {
let (border, background) = if is_selected {
(
Border::all(1.).with_border_fill(appearance.theme().accent()),
appearance.theme().find_bar_button_selection_color(),
)
} else if state.is_hovered() {
let hover_color = appearance.theme().foreground_button_color();
(Border::all(1.).with_border_fill(hover_color), hover_color)
} else {
let transparent = Fill::Solid(ColorU::transparent_black());
(Border::all(1.).with_border_fill(transparent), transparent)
};
let icon = Container::new(
ConstrainedBox::new(
icon.to_warpui_icon(appearance.theme().active_ui_text_color())
.finish(),
)
.with_height(size)
.with_width(size)
.finish(),
)
.with_uniform_padding(ICON_PADDING)
.with_vertical_margin(HORIZONTAL_ICON_SPACING)
.with_margin_right(right_margin)
.with_border(border)
.with_background(background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
ICON_CONTAINER_CORNER_RADIUS,
)))
.finish();
let mut stack = Stack::new().with_child(icon);
if let (Some(tooltip_text), true) = (tooltip_text, state.is_hovered()) {
let tooltip = appearance
.ui_builder()
.tool_tip(tooltip_text.to_string())
.build()
.finish();
stack.add_positioned_overlay_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., -5.),
ParentOffsetBounds::Unbounded,
ParentAnchor::TopMiddle,
ChildAnchor::BottomMiddle,
),
);
}
stack.finish()
})
.on_click(move |ctx, _app, _| ctx.dispatch_typed_action(on_click_action))
.finish()
}
fn render_next_match_button(
&self,
appearance: &Appearance,
hovered: bool,
direction: FindDirection,
height: f32,
app: &AppContext,
) -> Box<dyn Element> {
let background_color = if hovered && self.model.as_ref(app).match_count() > 0 {
appearance.theme().foreground_button_color()
} else {
Fill::Solid(ColorU::transparent_black())
};
let match_icon = match direction {
FindDirection::Down => Icon::ArrowDown,
FindDirection::Up => Icon::ArrowUp,
};
let icon_color = if self.model.as_ref(app).match_count() == 0 {
appearance.theme().nonactive_ui_text_color()
} else {
appearance.theme().active_ui_text_color()
};
Container::new(
ConstrainedBox::new(match_icon.to_warpui_icon(icon_color).finish())
.with_height(height)
.with_width(height)
.finish(),
)
.with_background(background_color)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
ICON_CONTAINER_CORNER_RADIUS,
)))
.with_uniform_padding(ICON_PADDING)
.finish()
}
fn render_close_button(
&self,
appearance: &Appearance,
hovered: bool,
height: f32,
) -> Box<dyn Element> {
let background_color = if hovered {
appearance.theme().foreground_button_color()
} else {
Fill::Solid(ColorU::transparent_black())
};
Container::new(
ConstrainedBox::new(
Icon::X
.to_warpui_icon(appearance.theme().active_ui_text_color())
.finish(),
)
.with_height(height)
.with_width(height)
.finish(),
)
.with_background(background_color)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
ICON_CONTAINER_CORNER_RADIUS,
)))
.with_uniform_padding(ICON_PADDING)
.finish()
}
}
impl<T: FindModel + Entity<Event = FindEvent> + 'static> Entity for Find<T> {
type Event = Event;
}
impl<T: FindModel + Entity<Event = FindEvent> + 'static> TypedActionView for Find<T> {
type Action = FindAction;
fn handle_action(&mut self, action: &FindAction, ctx: &mut ViewContext<Self>) {
match action {
FindAction::Up => self.focus_next_match(FindDirection::Up, ctx),
FindAction::CmdG => {
self.focus_next_match(T::default_find_direction(self.model.as_ref(ctx), ctx), ctx)
}
FindAction::Down => self.focus_next_match(FindDirection::Down, ctx),
FindAction::CmdShiftG => {
self.focus_next_match(T::alt_find_direction(self.model.as_ref(ctx), ctx), ctx)
}
FindAction::Close => self.close_find_bar(ctx),
FindAction::ToggleFindInBlock => self.toggle_find_within_block(ctx),
FindAction::ToggleCaseSensitivity => self.toggle_case_sensitivity(ctx),
FindAction::ToggleRegexSearch => self.toggle_regex_search(ctx),
}
}
}
impl<T: FindModel + Entity<Event = FindEvent> + 'static> View for Find<T> {
fn ui_name() -> &'static str {
"Find"
}
fn accessibility_contents(&self, _: &AppContext) -> Option<AccessibilityContent> {
Some(AccessibilityContent::new(
"Type searched phrase.",
"Press escape to quit, use enter and shift-enter to navigate between matches",
WarpA11yRole::TextareaRole,
))
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
self.editor.update(ctx, |editor, ctx| {
editor.select_all(ctx);
});
ctx.focus(&self.editor);
ctx.notify();
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let editor_height = self
.editor
.as_ref(app)
.line_height(app.font_cache(), appearance);
let mut query_editor_row =
Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let regex_icon = self.render_hoverable_icon_in_editor(
appearance,
Icon::Regex,
self.regex_search_enabled,
self.button_mouse_states.toggle_regex_search.clone(),
FindAction::ToggleRegexSearch,
editor_height,
Some(REGEX_TOGGLE_TOOLTIP),
ICON_PADDING,
);
let case_sensitive_icon = Container::new(
SavePosition::new(
self.render_hoverable_icon_in_editor(
appearance,
Icon::CaseSensitivity,
self.case_sensitivity_enabled,
self.button_mouse_states.toggle_case_sensitivity.clone(),
FindAction::ToggleCaseSensitivity,
editor_height,
Some(CASE_SENSITIVE_TOOLTIP),
ICON_PADDING,
),
"case_sensitive_button",
)
.finish(),
)
.finish();
let find_within_block_icon = Container::new(
SavePosition::new(
self.render_hoverable_icon_in_editor(
appearance,
Icon::CornersOfBox,
self.display_find_within_block == FindWithinBlockState::Enabled,
self.button_mouse_states.toggle_find_in_block.clone(),
FindAction::ToggleFindInBlock,
editor_height,
Some(FIND_WITHIN_BLOCK_TOOLTIP),
0.,
),
"find_in_block_button",
)
.finish(),
)
.finish();
let query_editor = Shrinkable::new(
1.,
ConstrainedBox::new(Clipped::new(ChildView::new(&self.editor).finish()).finish())
.with_height(editor_height)
.finish(),
)
.finish();
query_editor_row.add_child(query_editor);
query_editor_row.add_child(regex_icon);
query_editor_row.add_child(case_sensitive_icon);
if self.display_find_within_block != FindWithinBlockState::Hidden {
query_editor_row.add_child(find_within_block_icon);
}
let mut find_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Shrinkable::new(
1.,
Container::new(query_editor_row.finish())
.with_padding_right(4.)
.with_padding_left(8.)
.with_background(appearance.theme().surface_1())
.with_border(
Border::all(FIND_EDITOR_BORDER_WIDTH)
.with_border_fill(appearance.theme().surface_3()),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
FIND_EDITOR_BORDER_RADIUS,
)))
.with_margin_right(2. * HORIZONTAL_ICON_SPACING)
.finish(),
)
.finish(),
);
find_row.add_child(
Container::new(
ConstrainedBox::new(self.render_match_index(appearance, app))
.with_height(editor_height)
.finish(),
)
.with_margin_right(HORIZONTAL_ICON_SPACING)
.finish(),
);
find_row.add_child(
// down button
Container::new(
Hoverable::new(self.button_mouse_states.match_down.clone(), |state| {
self.render_next_match_button(
appearance,
state.is_hovered(),
FindDirection::Down,
editor_height,
app,
)
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(FindAction::Down);
})
.finish(),
)
.with_margin_left(HORIZONTAL_ICON_SPACING)
.finish(),
);
find_row.add_child(
// up button
Container::new(
Hoverable::new(self.button_mouse_states.match_up.clone(), |state| {
self.render_next_match_button(
appearance,
state.is_hovered(),
FindDirection::Up,
editor_height,
app,
)
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(FindAction::Up);
})
.finish(),
)
.with_margin_right(HORIZONTAL_ICON_SPACING)
.finish(),
);
find_row.add_child(
// close button
Container::new(
Hoverable::new(self.button_mouse_states.close.clone(), |state| {
self.render_close_button(appearance, state.is_hovered(), editor_height)
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(FindAction::Close);
})
.finish(),
)
.finish(),
);
let find_bar = Container::new(
ConstrainedBox::new(
Container::new(find_row.finish())
.with_background(appearance.theme().surface_2())
.finish(),
)
.with_height(editor_height + (2. * FIND_EDITOR_PADDING) + (2. * FIND_BAR_PADDING))
.with_width(FIND_BAR_WIDTH)
.finish(),
)
.with_uniform_padding(FIND_BAR_PADDING)
.with_background(appearance.theme().surface_2())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
FIND_EDITOR_BORDER_RADIUS,
)))
.with_drop_shadow(DropShadow::default())
.finish();
Align::new(
Container::new(find_bar)
.with_padding_top(10.)
.with_padding_right(20.)
.finish(),
)
.top_right()
.finish()
}
}
#[cfg(test)]
#[path = "find_tests.rs"]
mod tests;
+73
View File
@@ -0,0 +1,73 @@
use std::sync::Arc;
use warp_core::ui::appearance::Appearance;
use warpui::{platform::WindowStyle, App};
use crate::auth::AuthStateProvider;
use crate::server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient};
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::vim_registers::VimRegisters;
use crate::workspace::sync_inputs::SyncedInputState;
use crate::workspaces::user_workspaces::UserWorkspaces;
use super::{Find, FindDirection, FindEvent, FindModel};
struct MockFindModel;
impl warpui::Entity for MockFindModel {
type Event = FindEvent;
}
impl FindModel for MockFindModel {
fn focused_match_index(&self) -> Option<usize> {
None
}
fn match_count(&self) -> usize {
0
}
fn default_find_direction(&self, _app: &warpui::AppContext) -> FindDirection {
FindDirection::Down
}
}
fn initialize_test_app(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| SyncedInputState::mock());
app.add_singleton_model(|_| VimRegisters::new());
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
app.add_singleton_model(|ctx| {
UserWorkspaces::mock(
Arc::new(MockTeamClient::new()),
Arc::new(MockWorkspaceClient::new()),
vec![],
ctx,
)
});
}
#[test]
fn test_set_query_text_replaces_existing_text() {
App::test((), |mut app| async move {
initialize_test_app(&mut app);
let model = app.add_model(|_| MockFindModel);
let (_, find_view) =
app.add_window(WindowStyle::NotStealFocus, |ctx| Find::new(model, ctx));
find_view.update(&mut app, |view, ctx| {
view.set_query_text("first", ctx);
});
let text = find_view.read(&app, |view, ctx| view.editor_text(ctx));
assert_eq!(text, "first");
find_view.update(&mut app, |view, ctx| {
view.set_query_text("second", ctx);
});
let text = find_view.read(&app, |view, ctx| view.editor_text(ctx));
assert_eq!(text, "second");
})
}
@@ -0,0 +1,125 @@
use crate::appearance::Appearance;
use crate::notebooks::file::MarkdownDisplayMode;
use warpui::elements::{CornerRadius, Fill as UiFill, Radius};
use warpui::presenter::ChildView;
use warpui::ui_components::components::UiComponentStyles;
use warpui::ui_components::segmented_control::{
LabelConfig, RenderableOptionConfig, SegmentedControl, SegmentedControlEvent,
};
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
#[derive(Debug, Clone)]
pub enum MarkdownToggleEvent {
ModeSelected(MarkdownDisplayMode),
}
pub struct MarkdownToggleView {
segmented_control: ViewHandle<SegmentedControl<MarkdownDisplayMode>>,
}
impl MarkdownToggleView {
pub fn new(default_mode: MarkdownDisplayMode, ctx: &mut ViewContext<Self>) -> Self {
let segmented_control = ctx.add_typed_action_view(move |ctx| {
SegmentedControl::new(
vec![MarkdownDisplayMode::Rendered, MarkdownDisplayMode::Raw],
|mode, is_selected, app| {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
Some(RenderableOptionConfig {
icon_path: "",
icon_color: theme.main_text_color(theme.background()).into(),
label: Some(LabelConfig {
label: match mode {
MarkdownDisplayMode::Rendered => "Rendered".into(),
MarkdownDisplayMode::Raw => "Raw".into(),
},
width_override: Some(55.0),
color: if is_selected {
theme.accent().into()
} else {
theme.main_text_color(theme.background()).into()
},
}),
tooltip: None,
background: if is_selected {
UiFill::Solid(theme.surface_3().into())
} else {
UiFill::None
},
})
},
default_mode,
markdown_toggle_styles(ctx),
)
});
ctx.subscribe_to_view(&segmented_control, |_, _, event, ctx| {
let SegmentedControlEvent::OptionSelected(mode) = event;
ctx.emit(MarkdownToggleEvent::ModeSelected(*mode));
});
ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, _, ctx| {
me.segmented_control.update(ctx, |segmented_control, ctx| {
segmented_control.set_styles(markdown_toggle_styles(ctx), ctx);
});
ctx.notify();
});
Self { segmented_control }
}
pub fn set_selected_mode(&mut self, mode: MarkdownDisplayMode, ctx: &mut ViewContext<Self>) {
self.segmented_control.update(ctx, |control, ctx| {
control.set_selected_option(mode, ctx);
});
ctx.notify();
}
}
impl View for MarkdownToggleView {
fn ui_name() -> &'static str {
"MarkdownToggleView"
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
ChildView::new(&self.segmented_control).finish()
}
}
impl TypedActionView for MarkdownToggleView {
type Action = ();
fn handle_action(&mut self, _action: &Self::Action, ctx: &mut ViewContext<Self>) {
ctx.notify();
}
}
impl Entity for MarkdownToggleView {
type Event = MarkdownToggleEvent;
}
fn markdown_toggle_styles(app: &AppContext) -> UiComponentStyles {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(appearance.ui_font_size()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.0))),
border_width: Some(1.0),
border_color: Some(UiFill::Solid(theme.surface_3().into())),
background: Some(UiFill::Solid(theme.background().into())),
height: Some(20.0),
padding: Some(warpui::ui_components::components::Coords::uniform(0.0)),
margin: Some(warpui::ui_components::components::Coords {
top: 0.0,
bottom: 0.0,
left: 0.0,
right: 8.0,
}),
..Default::default()
}
}
+32
View File
@@ -0,0 +1,32 @@
//! This module is meant to house the app's reusable Views
pub mod action_button;
mod agent_toast;
pub mod alert;
pub mod callout_bubble;
mod clickable_text_input;
mod compact_dropdown;
pub mod compactible_action_button;
pub mod compactible_split_action_button;
pub mod copyable_text_field;
mod dismissible_toast;
pub mod dropdown;
mod feature_popup;
mod filterable_dropdown;
pub mod find;
mod markdown_toggle_view;
mod submittable_text_input;
mod warning_box;
pub use agent_toast::*;
pub use alert::Alert;
pub use clickable_text_input::*;
pub use compact_dropdown::{CompactDropdown, CompactDropdownEvent, CompactDropdownItem};
pub use copyable_text_field::*;
pub use dismissible_toast::*;
pub use dropdown::{Dropdown, DropdownEvent, DropdownItem};
pub use feature_popup::*;
pub use filterable_dropdown::{FilterableDropdown, FilterableDropdownOrientation};
pub use markdown_toggle_view::{MarkdownToggleEvent, MarkdownToggleView};
pub use submittable_text_input::*;
pub use warning_box::*;
@@ -0,0 +1,242 @@
use pathfinder_color::ColorU;
use warpui::{
elements::{
Border, Container, CornerRadius, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, Radius, Shrinkable,
},
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::{
appearance::Appearance,
editor::{EditorOptions, EditorView, Event as EditorEvent, InteractionState, TextOptions},
themes::theme::Fill,
};
const ENTER_BUTTON_SIZE: f32 = 22.;
enum ValidatorType {
/// Validates whenever the input changes.
OnEdit,
/// Only validates on submission.
OnSubmitOnly,
}
/// This View is a text input in which you can submit its contents by clicking the embedded button
/// or pressing Enter.
pub struct SubmittableTextInput {
editor: ViewHandle<EditorView>,
/// A closure that returns if the current editor content are valid and can be submitted.
validator: Box<dyn Fn(&str) -> bool>,
validator_type: ValidatorType,
/// Whether or not the last edit made the contents valid.
has_error: bool,
submit_button_state: MouseStateHandle,
outer_margin_top: f32,
outer_margin_bottom: f32,
}
impl SubmittableTextInput {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let options = EditorOptions {
autogrow: true,
soft_wrap: true,
text: TextOptions::ui_font_size(appearance),
..Default::default()
};
EditorView::new(options, ctx)
});
ctx.subscribe_to_view(&editor, Self::handle_editor_event);
Self {
editor,
validator: Box::new(|_| true),
validator_type: ValidatorType::OnEdit,
has_error: false,
submit_button_state: Default::default(),
outer_margin_top: 10.,
outer_margin_bottom: 10.,
}
}
/// Validates the input contents using the provided `validator`
/// on every edit action.
pub fn validate_on_edit<F: Fn(&str) -> bool + 'static>(mut self, validator: F) -> Self {
self.validator_type = ValidatorType::OnEdit;
self.validator = Box::new(validator);
self
}
/// Validates the input contents using the provided `validator`
/// whenever a submit action is attempted.
pub fn validate_on_submit<F: Fn(&str) -> bool + 'static>(mut self, validator: F) -> Self {
self.validator_type = ValidatorType::OnSubmitOnly;
self.validator = Box::new(validator);
self
}
pub fn set_placeholder_text(&mut self, text: impl Into<String>, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(text, ctx);
});
}
pub fn set_outer_margins(&mut self, top: f32, bottom: f32, ctx: &mut ViewContext<Self>) {
self.outer_margin_top = top;
self.outer_margin_bottom = bottom;
ctx.notify();
}
/// Returns a handle to the backing [`EditorView`].
pub fn editor(&self) -> &ViewHandle<EditorView> {
&self.editor
}
fn handle_editor_event(
&mut self,
_handle: ViewHandle<EditorView>,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
// Pressing Enter is the user attempting to submit the entry.
EditorEvent::Enter => self.on_try_submit(ctx),
// Every time the editor contents are changed, we check if the contents are now valid
// so we can update the border and enable/disable the button.
EditorEvent::Edited(_) => {
let content = self.editor.read(ctx, |editor, ctx| editor.buffer_text(ctx));
self.has_error = match self.validator_type {
ValidatorType::OnEdit => !(self.validator)(&content),
ValidatorType::OnSubmitOnly => false,
};
ctx.notify();
}
EditorEvent::Escape => ctx.emit(SubmittableTextInputEvent::Escape),
_ => {}
}
}
fn on_try_submit(&mut self, ctx: &mut ViewContext<Self>) {
let content = self
.editor
.read(ctx, |editor, ctx| editor.buffer_text(ctx).trim().to_owned());
if content.is_empty() {
return;
}
if !(self.validator)(&content) {
self.has_error = true;
ctx.notify();
} else {
self.editor
.update(ctx, |editor, ctx| editor.clear_buffer(ctx));
ctx.emit(SubmittableTextInputEvent::Submit(content))
}
}
}
impl View for SubmittableTextInput {
fn ui_name() -> &'static str {
"SubmittableTextInput"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus(&self.editor);
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let border_fill = if self.has_error {
appearance.theme().ui_error_color().into()
} else {
appearance.theme().outline()
};
let mut submit_button = appearance
.ui_builder()
.enter_button(ENTER_BUTTON_SIZE, self.submit_button_state.clone())
.with_style(UiComponentStyles {
padding: Some(Coords::uniform(4.)),
..Default::default()
})
.build();
if self.has_error
|| self.editor.as_ref(app).interaction_state(app) == InteractionState::Disabled
{
submit_button = submit_button.disable();
}
Container::new(
Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_children([
Shrinkable::new(
1.,
appearance
.ui_builder()
.text_input(self.editor.clone())
.with_style(UiComponentStyles {
background: Some(Fill::Solid(ColorU::transparent_black()).into()),
border_color: Some(Fill::Solid(ColorU::transparent_black()).into()),
padding: Some(Coords::uniform(8.)),
..Default::default()
})
.build()
.finish(),
)
.finish(),
submit_button
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(SubmittableTextInputAction::Submit)
})
.finish(),
])
.finish(),
)
.with_border(Border::all(1.).with_border_fill(border_fill))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_margin_top(self.outer_margin_top)
.with_margin_bottom(self.outer_margin_bottom)
.with_padding_left(4.)
.with_padding_right(8.)
.finish()
}
}
#[derive(Debug)]
pub enum SubmittableTextInputEvent {
/// Notify the subscribers (parent view) of the submission.
Submit(String),
Escape,
}
impl Entity for SubmittableTextInput {
type Event = SubmittableTextInputEvent;
}
#[derive(Debug)]
pub enum SubmittableTextInputAction {
/// The user expressing the intent to submit. Only follow through with propagating this if the
/// input is valid as determined by the validator closure.
Submit,
}
impl TypedActionView for SubmittableTextInput {
type Action = SubmittableTextInputAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
SubmittableTextInputAction::Submit => self.on_try_submit(ctx),
}
}
}
+226
View File
@@ -0,0 +1,226 @@
//! A reusable warning callout component with optional action button.
use warp_core::ui::color::blend::Blend;
use warpui::color::ColorU;
use warpui::elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Expanded, Flex,
Hoverable, MainAxisSize, MouseStateHandle, ParentElement, Radius, Text,
};
use warpui::platform::Cursor;
use warpui::EventContext;
use crate::appearance::Appearance;
use crate::themes::theme::Fill as ThemeFill;
use crate::ui_components::icons::Icon;
pub struct WarningBoxButtonConfig {
pub label: String,
pub mouse_state: MouseStateHandle,
pub on_click: Box<dyn Fn(&mut EventContext) + 'static>,
}
impl WarningBoxButtonConfig {
pub fn new(
label: impl Into<String>,
mouse_state: MouseStateHandle,
on_click: impl Fn(&mut EventContext) + 'static,
) -> Self {
Self {
label: label.into(),
mouse_state,
on_click: Box::new(on_click),
}
}
}
pub struct WarningBoxConfig {
pub icon: Icon,
pub title: String,
pub description: Option<String>,
/// Optional max width. If provided, the WarningBox will not exceed this width,
/// but can shrink on smaller screens.
pub width: Option<f32>,
pub margin_top: f32,
pub button: Option<WarningBoxButtonConfig>,
}
impl WarningBoxConfig {
pub fn new(title: impl Into<String>) -> Self {
Self {
icon: Icon::AlertTriangle,
title: title.into(),
description: None,
width: None,
margin_top: 8.,
button: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_icon(mut self, icon: Icon) -> Self {
self.icon = icon;
self
}
pub fn with_width(mut self, width: f32) -> Self {
self.width = Some(width);
self
}
pub fn with_button(mut self, button: WarningBoxButtonConfig) -> Self {
self.button = Some(button);
self
}
}
pub fn render_warning_box(config: WarningBoxConfig, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let icon_size = appearance.ui_font_size() * 1.1;
let warning_color = theme.ui_warning_color();
// Use a lighter yellow for readability while still clearly communicating “warning”.
let text_color: ColorU = ThemeFill::Solid(theme.ui_yellow_color())
.blend(&theme.foreground().with_opacity(70))
.into();
let warning_fill = ThemeFill::Solid(warning_color);
let icon_fill = ThemeFill::Solid(text_color);
let background = theme.surface_2().blend(&warning_fill.with_opacity(15));
let mut text_col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(2.)
.with_child(
Text::new(
config.title,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(text_color)
.soft_wrap(true)
.finish(),
);
if let Some(description) = config.description {
text_col.add_child(
Text::new(
description,
appearance.ui_font_family(),
appearance.ui_font_size() * 0.9,
)
.with_color(text_color)
.soft_wrap(true)
.finish(),
);
}
// Treat warning boxes as flexible by default so they wrap and shrink with their container.
let should_use_flex = true;
let has_action_button = config.button.is_some();
let left = if should_use_flex {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(12.)
.with_child(
ConstrainedBox::new(config.icon.to_warpui_icon(icon_fill).finish())
.with_width(icon_size)
.with_height(icon_size)
.finish(),
)
.with_child(Expanded::new(1., text_col.finish()).finish())
.finish()
} else {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(12.)
.with_child(
ConstrainedBox::new(config.icon.to_warpui_icon(icon_fill).finish())
.with_width(icon_size)
.with_height(icon_size)
.finish(),
)
.with_child(text_col.finish())
.finish()
};
let action_button = config.button.map(|button| {
let WarningBoxButtonConfig {
label,
mouse_state,
on_click,
} = button;
Hoverable::new(mouse_state, move |state| {
let bg = if state.is_mouse_over_element() {
theme.surface_2()
} else {
theme.surface_3()
};
Container::new(
Text::new(
label.clone(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(theme.active_ui_text_color().into())
.finish(),
)
.with_horizontal_padding(12.)
.with_vertical_padding(8.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_background(bg)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
(on_click)(ctx);
})
.finish()
});
let mut row = Flex::row()
.with_cross_axis_alignment(if has_action_button {
CrossAxisAlignment::Center
} else {
CrossAxisAlignment::Start
})
.with_spacing(12.);
if should_use_flex {
row = row.with_main_axis_size(MainAxisSize::Max);
row.add_child(Expanded::new(1., left).finish());
} else {
row.add_child(left);
}
if let Some(action_button) = action_button {
row.add_child(action_button);
}
let mut element = ConstrainedBox::new(
Container::new(row.finish())
.with_margin_top(config.margin_top)
.with_uniform_padding(12.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_background(background)
.finish(),
);
if let Some(width) = config.width {
element = element.with_max_width(width);
}
element.finish()
}