Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user