Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
use warpui::{
|
||||
elements::{
|
||||
self, Align, Border, CacheOption, ConstrainedBox, Container, Element, Image, ParentElement,
|
||||
Text,
|
||||
},
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
};
|
||||
|
||||
use super::red_notification_dot::RedNotificationDot;
|
||||
use warp_core::ui::{external_product_icon::ExternalProductIcon, icons::Icon};
|
||||
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::elements::{ChildAnchor, OffsetPositioning, ParentAnchor, ParentOffsetBounds, Stack};
|
||||
|
||||
pub enum AvatarContent {
|
||||
/// Rendered as capital initial of the given display name.
|
||||
DisplayName(String),
|
||||
|
||||
/// Renders the icon directly.
|
||||
Icon(Icon),
|
||||
|
||||
ExternalProductIcon(ExternalProductIcon),
|
||||
|
||||
/// Renders the image on a colored background.
|
||||
Image {
|
||||
url: String,
|
||||
/// The first initial is rendered prior to loading the image.
|
||||
display_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum StatusElementTypes {
|
||||
Circle,
|
||||
Icon(Icon),
|
||||
}
|
||||
|
||||
/// Avatar UI component.
|
||||
pub struct Avatar {
|
||||
content: AvatarContent,
|
||||
styles: UiComponentStyles,
|
||||
/// If this is set, we will render a status symbol on the upper right corner of the avatar.
|
||||
status_element_type: Option<StatusElementTypes>,
|
||||
// Styles for the status
|
||||
status_styles: Option<UiComponentStyles>,
|
||||
/// Optional additional offset for the status indicator (x to the right, y downward).
|
||||
status_offset: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl UiComponent for Avatar {
|
||||
type ElementType = Container;
|
||||
fn build(self) -> Container {
|
||||
let styles = self.styles;
|
||||
let inner_element = match self.content {
|
||||
AvatarContent::Image { url, display_name } => {
|
||||
let mut image = Image::new(asset_cache::url_source(url), CacheOption::BySize)
|
||||
.before_load(
|
||||
Align::new(Self::first_initial(&display_name, self.styles)).finish(),
|
||||
);
|
||||
if let Some(radius) = styles.border_radius {
|
||||
image = image.with_corner_radius(radius);
|
||||
}
|
||||
image.finish()
|
||||
}
|
||||
AvatarContent::Icon(icon) => {
|
||||
let icon_size = {
|
||||
let height = styles.height.unwrap_or_default();
|
||||
// One third of the total avatar height/width should be padding.
|
||||
height * 0.66
|
||||
};
|
||||
ConstrainedBox::new(
|
||||
elements::Icon::new(icon.into(), styles.font_color.unwrap_or_default())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish()
|
||||
}
|
||||
AvatarContent::ExternalProductIcon(external_product_icon) => {
|
||||
let icon_size = {
|
||||
let height = styles.height.unwrap_or_default();
|
||||
// One third of the total avatar height/width should be padding.
|
||||
height * 0.66
|
||||
};
|
||||
ConstrainedBox::new(
|
||||
elements::Icon::new(
|
||||
external_product_icon.get_path(),
|
||||
styles.font_color.unwrap_or_default(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish()
|
||||
}
|
||||
AvatarContent::DisplayName(name) => Self::first_initial(&name, self.styles),
|
||||
};
|
||||
|
||||
let mut constrained_box = ConstrainedBox::new(Align::new(inner_element).finish());
|
||||
|
||||
if let Some(height) = styles.height {
|
||||
constrained_box = constrained_box.with_height(height);
|
||||
}
|
||||
if let Some(width) = styles.width {
|
||||
constrained_box = constrained_box.with_width(width);
|
||||
}
|
||||
|
||||
let mut container = Container::new(
|
||||
if let Some(status_element_type) = self.status_element_type {
|
||||
let offset = self.status_offset.unwrap_or((0., 0.));
|
||||
let status_styles = self.status_styles.unwrap_or_default();
|
||||
match status_element_type {
|
||||
StatusElementTypes::Circle => RedNotificationDot::render_with_offset(
|
||||
constrained_box.finish(),
|
||||
&status_styles,
|
||||
offset,
|
||||
),
|
||||
StatusElementTypes::Icon(icon) => Self::render_icon_with_offset(
|
||||
constrained_box.finish(),
|
||||
icon,
|
||||
&status_styles,
|
||||
offset,
|
||||
),
|
||||
}
|
||||
} else {
|
||||
constrained_box.finish()
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(corner) = styles.border_radius {
|
||||
container = container.with_corner_radius(corner);
|
||||
}
|
||||
let mut border = Border::all(styles.border_width.unwrap_or_default());
|
||||
if let Some(border_color) = styles.border_color {
|
||||
border = border.with_border_fill(border_color);
|
||||
}
|
||||
container = container.with_border(border);
|
||||
|
||||
if let Some(padding) = styles.padding {
|
||||
container = container
|
||||
.with_padding_left(padding.left)
|
||||
.with_padding_top(padding.top)
|
||||
.with_padding_right(padding.right)
|
||||
.with_padding_bottom(padding.bottom);
|
||||
}
|
||||
|
||||
if let Some(margin) = styles.margin {
|
||||
container = container
|
||||
.with_margin_left(margin.left)
|
||||
.with_margin_top(margin.top)
|
||||
.with_margin_right(margin.right)
|
||||
.with_margin_bottom(margin.bottom);
|
||||
}
|
||||
|
||||
if let Some(background) = styles.background {
|
||||
container = container.with_background(background);
|
||||
}
|
||||
|
||||
container
|
||||
}
|
||||
|
||||
fn with_style(self, styles: UiComponentStyles) -> Self {
|
||||
Avatar {
|
||||
styles: self.styles.merge(styles),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
pub fn new(content: AvatarContent, styles: UiComponentStyles) -> Self {
|
||||
Avatar {
|
||||
content,
|
||||
styles,
|
||||
status_element_type: None,
|
||||
status_styles: None,
|
||||
status_offset: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_status_element(
|
||||
mut self,
|
||||
status_element_type: StatusElementTypes,
|
||||
status_styles: UiComponentStyles,
|
||||
) -> Self {
|
||||
self.status_element_type = Some(status_element_type);
|
||||
self.status_styles = Some(status_styles);
|
||||
self.status_offset = None;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_status_element_with_offset(
|
||||
mut self,
|
||||
status_element_type: StatusElementTypes,
|
||||
status_styles: UiComponentStyles,
|
||||
x_delta: f32,
|
||||
y_delta: f32,
|
||||
) -> Self {
|
||||
self.status_element_type = Some(status_element_type);
|
||||
self.status_styles = Some(status_styles);
|
||||
self.status_offset = Some((x_delta, y_delta));
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns an element with the first initial of the user, capitalized.
|
||||
/// Note: Unicode characters can be more than one byte, and uppercasing a unicode character
|
||||
/// can produce more than one character. For example, the uppercase of ß is SS.
|
||||
/// In that case we take the first character (just S).
|
||||
fn first_initial(display_name: &str, styles: UiComponentStyles) -> Box<dyn Element> {
|
||||
Text::new_inline(
|
||||
display_name
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_uppercase()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
styles.font_family_id.expect("text must have font family"),
|
||||
styles.font_size.unwrap_or_default(),
|
||||
)
|
||||
.with_color(styles.font_color.unwrap_or_default())
|
||||
.with_style(styles.font_properties())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_icon_with_offset(
|
||||
element: Box<dyn Element>,
|
||||
icon: Icon,
|
||||
styles: &UiComponentStyles,
|
||||
(x_delta, y_delta): (f32, f32),
|
||||
) -> Box<dyn Element> {
|
||||
let icon_size = styles.width.unwrap_or(12.0);
|
||||
let x_axis_offset = icon_size / 2.;
|
||||
let y_axis_offset = -(icon_size / 2.);
|
||||
|
||||
let icon_element = ConstrainedBox::new(
|
||||
elements::Icon::new(icon.into(), styles.font_color.unwrap_or_default()).finish(),
|
||||
)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(element);
|
||||
stack.add_positioned_child(
|
||||
icon_element,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(x_axis_offset + x_delta, y_axis_offset + y_delta),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
stack.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub use warp_core::ui::theme::color::internal_colors::*;
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use itertools::{Itertools, Position};
|
||||
use warpui::{
|
||||
elements::{
|
||||
CrossAxisAlignment, Flex, Hoverable, MainAxisSize, MouseStateHandle, ParentElement,
|
||||
Shrinkable,
|
||||
},
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, EventContext,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
|
||||
/// A value which may be rendered as a breadcrumb.
|
||||
pub trait Breadcrumb: Debug + 'static {
|
||||
/// The label to display for this breadcrumb.
|
||||
fn label(&self) -> String;
|
||||
|
||||
/// Whether or not this breadcrumb is enabled and interactive.
|
||||
fn enabled(&self) -> bool;
|
||||
}
|
||||
|
||||
impl Breadcrumb for String {
|
||||
fn label(&self) -> String {
|
||||
self.clone()
|
||||
}
|
||||
|
||||
fn enabled(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// This implementation is for cases where a breadcrumb type is required but unused, such as panes
|
||||
/// that do not have any breadcrumbs.
|
||||
impl Breadcrumb for () {
|
||||
fn label(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn enabled(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// State for a breadcrumb component.
|
||||
#[derive(Clone)]
|
||||
pub struct BreadcrumbState<T: Breadcrumb> {
|
||||
breadcrumb: T,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl<T: Breadcrumb> BreadcrumbState<T> {
|
||||
pub fn new(breadcrumb: T) -> Self {
|
||||
Self {
|
||||
breadcrumb,
|
||||
mouse_state_handle: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single breadcrumb.
|
||||
fn render_breadcrumb<T: Breadcrumb>(
|
||||
state: &BreadcrumbState<T>,
|
||||
is_last_item: bool,
|
||||
appearance: &Appearance,
|
||||
) -> Hoverable {
|
||||
let suffix = if is_last_item { "" } else { " / " };
|
||||
let name = state.breadcrumb.label() + suffix;
|
||||
|
||||
let hoverable = Hoverable::new(state.mouse_state_handle.clone(), |mouse_state| {
|
||||
let font_color = if mouse_state.is_hovered() || mouse_state.is_clicked() {
|
||||
appearance.theme().active_ui_text_color()
|
||||
} else {
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
};
|
||||
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(name)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(font_color.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish()
|
||||
});
|
||||
if state.breadcrumb.enabled() {
|
||||
hoverable
|
||||
} else {
|
||||
hoverable.disable()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a row of interactive breadcrumbs.
|
||||
pub fn render_breadcrumbs<T, I>(
|
||||
breadcrumbs: I,
|
||||
appearance: &Appearance,
|
||||
on_click: fn(&mut EventContext, &AppContext, &T) -> (),
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
T: Breadcrumb,
|
||||
I: IntoIterator<Item = BreadcrumbState<T>>,
|
||||
{
|
||||
let children = breadcrumbs
|
||||
.into_iter()
|
||||
.with_position()
|
||||
.map(|(position, breadcrumb)| {
|
||||
// Each breadcrumb is expanded so that it inherits the parent `Flex`'s size constraint.
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
render_breadcrumb(
|
||||
&breadcrumb,
|
||||
matches!(position, Position::Last | Position::Only),
|
||||
appearance,
|
||||
)
|
||||
.on_click(move |ctx, app, _| on_click(ctx, app, &breadcrumb.breadcrumb))
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
});
|
||||
|
||||
Flex::row()
|
||||
.with_children(children)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
use super::icons::{Icon, ICON_DIMENSIONS};
|
||||
use super::{blended_colors, BORDER_RADIUS};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::themes::theme::Fill;
|
||||
use crate::themes::theme::WarpTheme;
|
||||
use warpui::elements::Radius;
|
||||
use warpui::elements::{CornerRadius, MouseStateHandle};
|
||||
use warpui::ui_components::button::Button;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
|
||||
const ICON_BUTTON_PADDING: f32 = 4.;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum ButtonMode {
|
||||
Base,
|
||||
#[allow(dead_code)]
|
||||
Accent,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum ButtonState {
|
||||
Default,
|
||||
Disabled,
|
||||
Pressed,
|
||||
Hover,
|
||||
}
|
||||
|
||||
/// Utility struct that wraps all styles required for the button
|
||||
pub struct AllButtonStyles {
|
||||
default_styles: UiComponentStyles,
|
||||
hovered_styles: Option<UiComponentStyles>,
|
||||
clicked_styles: Option<UiComponentStyles>,
|
||||
disabled_styles: Option<UiComponentStyles>,
|
||||
}
|
||||
|
||||
fn all_icon_button_styles(warp_theme: &WarpTheme, mode: ButtonMode) -> AllButtonStyles {
|
||||
AllButtonStyles {
|
||||
default_styles: icon_button_styles(warp_theme, mode, ButtonState::Default),
|
||||
hovered_styles: Some(icon_button_styles(warp_theme, mode, ButtonState::Hover)),
|
||||
clicked_styles: Some(icon_button_styles(warp_theme, mode, ButtonState::Pressed)),
|
||||
disabled_styles: Some(icon_button_styles(warp_theme, mode, ButtonState::Disabled)),
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_button_styles(
|
||||
warp_theme: &WarpTheme,
|
||||
mode: ButtonMode,
|
||||
state: ButtonState,
|
||||
) -> UiComponentStyles {
|
||||
let icon_color = icon_color(warp_theme, mode);
|
||||
|
||||
let (background_color, border_color): (Option<Fill>, Option<Fill>) = match (mode, state) {
|
||||
(ButtonMode::Base, ButtonState::Default) => (None, None),
|
||||
(ButtonMode::Base, ButtonState::Hover) => {
|
||||
(Some(warp_theme.surface_2()), Some(warp_theme.surface_3()))
|
||||
}
|
||||
(ButtonMode::Base, ButtonState::Pressed) | (ButtonMode::Base, ButtonState::Disabled) => {
|
||||
(Some(warp_theme.background()), Some(warp_theme.surface_3()))
|
||||
}
|
||||
(ButtonMode::Accent, ButtonState::Default) => (None, None),
|
||||
(ButtonMode::Accent, ButtonState::Hover) => (
|
||||
Some(warp_theme.surface_3()),
|
||||
Some(blended_colors::accent(warp_theme)),
|
||||
),
|
||||
(ButtonMode::Accent, ButtonState::Pressed)
|
||||
| (ButtonMode::Accent, ButtonState::Disabled) => (
|
||||
Some(warp_theme.background()),
|
||||
Some(blended_colors::accent_pressed(warp_theme)),
|
||||
),
|
||||
};
|
||||
|
||||
let mut styles = UiComponentStyles::default()
|
||||
.set_width(ICON_DIMENSIONS)
|
||||
.set_height(ICON_DIMENSIONS)
|
||||
.set_border_width(0.)
|
||||
.set_padding(Coords::uniform(ICON_BUTTON_PADDING))
|
||||
.set_border_radius(CornerRadius::with_all(Radius::Pixels(BORDER_RADIUS)))
|
||||
.set_font_color(icon_color.into());
|
||||
|
||||
if let Some(border_color) = border_color {
|
||||
styles = styles.set_border_color(border_color.into());
|
||||
}
|
||||
if let Some(background_color) = background_color {
|
||||
styles = styles.set_background(background_color.into());
|
||||
}
|
||||
styles
|
||||
}
|
||||
|
||||
fn combo_inner_button_styles(warp_theme: &WarpTheme, state: ButtonState) -> UiComponentStyles {
|
||||
let background = match state {
|
||||
ButtonState::Default => None,
|
||||
ButtonState::Hover => Some(blended_colors::neutral_2(warp_theme)),
|
||||
ButtonState::Pressed => Some(blended_colors::neutral_4(warp_theme)),
|
||||
ButtonState::Disabled => Some(warp_theme.background().into()),
|
||||
};
|
||||
|
||||
UiComponentStyles {
|
||||
width: Some(ICON_DIMENSIONS),
|
||||
height: Some(ICON_DIMENSIONS),
|
||||
border_width: None,
|
||||
padding: Some(Coords::uniform(ICON_BUTTON_PADDING - 1.)),
|
||||
border_radius: None,
|
||||
font_color: Some(warp_theme.foreground().into()),
|
||||
border_color: None,
|
||||
background: background.map(Into::into),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// This creates an inner icon_button for the purpose of adding it into a
|
||||
/// combo button. In these cases, the icon_button should not have a border
|
||||
/// as the combo button will provide these. Note that b/c
|
||||
/// it is not needed at this time, disabled is not implemented.
|
||||
///
|
||||
/// TODO(CORE-2300): Evaluate whether or not this helper makes sense in this
|
||||
/// location, as it is only used in workspace/view.rs right now (it is here
|
||||
/// b/c of access to non-pub fields).
|
||||
pub fn combo_inner_button(
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
active: bool,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
) -> Button {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let button = Button::new(
|
||||
mouse_state_handle,
|
||||
combo_inner_button_styles(theme, ButtonState::Default),
|
||||
Some(combo_inner_button_styles(theme, ButtonState::Hover)),
|
||||
Some(combo_inner_button_styles(theme, ButtonState::Pressed)),
|
||||
Some(combo_inner_button_styles(theme, ButtonState::Disabled)),
|
||||
)
|
||||
.with_icon_label(icon.to_warpui_icon(theme.foreground()));
|
||||
|
||||
if active {
|
||||
return button.active();
|
||||
}
|
||||
button
|
||||
}
|
||||
|
||||
fn icon_color(warp_theme: &WarpTheme, mode: ButtonMode) -> Fill {
|
||||
match mode {
|
||||
ButtonMode::Base => warp_theme.foreground(),
|
||||
ButtonMode::Accent => blended_colors::accent(warp_theme),
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_button_internal(
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
active: bool,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
mode: ButtonMode,
|
||||
mut color: Option<Fill>,
|
||||
) -> Button {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let button_styles = all_icon_button_styles(theme, mode);
|
||||
let mut button = Button::new(
|
||||
mouse_state_handle,
|
||||
button_styles.default_styles,
|
||||
button_styles.hovered_styles,
|
||||
button_styles.clicked_styles,
|
||||
button_styles.disabled_styles,
|
||||
)
|
||||
.with_icon_label(icon.to_warpui_icon(color.unwrap_or(icon_color(theme, mode))));
|
||||
|
||||
if let Some(color) = color.take() {
|
||||
// We also need to set the font color here to get the button to be colored correctly.
|
||||
button = button.with_style(UiComponentStyles::default().set_font_color(color.into()));
|
||||
}
|
||||
|
||||
if active {
|
||||
return button.active();
|
||||
}
|
||||
button
|
||||
}
|
||||
|
||||
pub fn icon_button_with_color(
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
active: bool,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
color: Fill,
|
||||
) -> Button {
|
||||
icon_button_internal(
|
||||
appearance,
|
||||
icon,
|
||||
active,
|
||||
mouse_state_handle,
|
||||
ButtonMode::Base,
|
||||
Some(color),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn icon_button(
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
active: bool,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
) -> Button {
|
||||
icon_button_internal(
|
||||
appearance,
|
||||
icon,
|
||||
active,
|
||||
mouse_state_handle,
|
||||
ButtonMode::Base,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn accent_icon_button(
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
active: bool,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
) -> Button {
|
||||
icon_button_internal(
|
||||
appearance,
|
||||
icon,
|
||||
active,
|
||||
mouse_state_handle,
|
||||
ButtonMode::Accent,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn close_button(appearance: &Appearance, mouse_state_handle: MouseStateHandle) -> Button {
|
||||
icon_button(appearance, Icon::X, false, mouse_state_handle)
|
||||
}
|
||||
|
||||
pub fn highlight(button: Button, appearance: &Appearance) -> Button {
|
||||
button
|
||||
.with_style(UiComponentStyles::default().set_font_color(
|
||||
crate::ui_components::blended_colors::text_main(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
),
|
||||
))
|
||||
.with_hovered_styles(
|
||||
UiComponentStyles::default()
|
||||
.set_background(appearance.theme().surface_3().into())
|
||||
.set_font_color(appearance.theme().foreground().into()),
|
||||
)
|
||||
.with_clicked_styles(
|
||||
UiComponentStyles::default()
|
||||
.set_background(appearance.theme().background().into())
|
||||
.set_font_color(appearance.theme().foreground().into()),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use ui_components::tooltip::{Params as TooltipParams, Tooltip as TooltipComponent};
|
||||
use ui_components::{Component as _, Options as ComponentOptions};
|
||||
use warp_core::ui::theme::{AnsiColorIdentifier, Fill as ThemeFill};
|
||||
use warpui::elements::{
|
||||
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, Element, Hoverable,
|
||||
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
|
||||
Stack,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
const COLOR_DOT_SIZE: f32 = 16.;
|
||||
|
||||
pub(crate) const TAB_COLOR_OPTIONS: [AnsiColorIdentifier; 6] = [
|
||||
AnsiColorIdentifier::Red,
|
||||
AnsiColorIdentifier::Green,
|
||||
AnsiColorIdentifier::Yellow,
|
||||
AnsiColorIdentifier::Blue,
|
||||
AnsiColorIdentifier::Magenta,
|
||||
AnsiColorIdentifier::Cyan,
|
||||
];
|
||||
|
||||
/// Renders a hoverable color dot with selection ring, tooltip, and pointer cursor.
|
||||
/// For the no-color option, pass `is_no_color: true` to show a slash overlay.
|
||||
/// Returns a `Hoverable` so callers can chain `.on_click(...)` before `.finish()`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn render_color_dot(
|
||||
mouse_state: MouseStateHandle,
|
||||
dot_color: ColorU,
|
||||
is_selected: bool,
|
||||
ring_color: ColorU,
|
||||
is_no_color: bool,
|
||||
foreground_color: ThemeFill,
|
||||
tooltip_text: String,
|
||||
appearance: &Appearance,
|
||||
) -> Hoverable {
|
||||
Hoverable::new(mouse_state, move |state| {
|
||||
let overlay: Option<Box<dyn Element>> = if is_no_color {
|
||||
Some(Icon::SlashCircle.to_warpui_icon(foreground_color).finish())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let dot_element = render_dot_element(dot_color, is_selected, ring_color, overlay);
|
||||
|
||||
if state.is_hovered() {
|
||||
let tooltip_element = TooltipComponent.render(
|
||||
appearance,
|
||||
TooltipParams {
|
||||
label: tooltip_text.clone().into(),
|
||||
options: ComponentOptions::default(appearance),
|
||||
},
|
||||
);
|
||||
Stack::new()
|
||||
.with_child(dot_element)
|
||||
.with_positioned_child(
|
||||
tooltip_element,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -4.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
)
|
||||
.finish()
|
||||
} else {
|
||||
dot_element
|
||||
}
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
}
|
||||
|
||||
/// Pure visual element: circular dot with optional overlay and selection ring.
|
||||
fn render_dot_element(
|
||||
dot_color: ColorU,
|
||||
is_selected: bool,
|
||||
ring_color: ColorU,
|
||||
overlay: Option<Box<dyn Element>>,
|
||||
) -> Box<dyn Element> {
|
||||
let dot = ConstrainedBox::new(Icon::Ellipse.to_warpui_icon(dot_color.into()).finish())
|
||||
.with_width(COLOR_DOT_SIZE)
|
||||
.with_height(COLOR_DOT_SIZE)
|
||||
.finish();
|
||||
|
||||
let inner = if let Some(overlay_element) = overlay {
|
||||
let overlay_sized = ConstrainedBox::new(overlay_element)
|
||||
.with_width(COLOR_DOT_SIZE)
|
||||
.with_height(COLOR_DOT_SIZE)
|
||||
.finish();
|
||||
Stack::new()
|
||||
.with_child(dot)
|
||||
.with_child(overlay_sized)
|
||||
.finish()
|
||||
} else {
|
||||
dot
|
||||
};
|
||||
|
||||
let border_color = if is_selected {
|
||||
ring_color
|
||||
} else {
|
||||
ColorU::transparent_black()
|
||||
};
|
||||
|
||||
Container::new(inner)
|
||||
.with_border(Border::all(2.).with_border_color(border_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
use super::blended_colors;
|
||||
use crate::appearance::Appearance;
|
||||
use warpui::elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Element, Flex,
|
||||
MainAxisAlignment, MainAxisSize, ParentElement, Shrinkable, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
|
||||
const DIALOG_PADDING: f32 = 20.;
|
||||
|
||||
/// UiComponent that implements a dialog.
|
||||
/// As such by default it's only a box with title and a child (whatever's in the middle of the
|
||||
/// dialog) wrapped in a Dismiss, however, it provides couple methods that allow for adding extra
|
||||
/// elements to the bottom row (like buttons, or links to documentation), and a close button.
|
||||
/// UiComponent::build method returns Dismiss so the user can add their own on_dismiss action.
|
||||
pub struct Dialog {
|
||||
bottom_row: Vec<Box<dyn Element>>,
|
||||
bottom_row_left: Vec<Box<dyn Element>>,
|
||||
title: String,
|
||||
body: Option<String>,
|
||||
child: Option<Box<dyn Element>>,
|
||||
styles: UiComponentStyles,
|
||||
close_button: Option<Box<dyn Element>>,
|
||||
show_separator: bool,
|
||||
}
|
||||
|
||||
pub fn dialog_styles(appearance: &Appearance) -> UiComponentStyles {
|
||||
let theme = appearance.theme();
|
||||
let background = theme.surface_1();
|
||||
UiComponentStyles {
|
||||
font_family_id: Some(appearance.header_font_family()),
|
||||
font_size: Some(16.),
|
||||
font_color: Some(blended_colors::text_main(theme, background)),
|
||||
font_weight: Some(warpui::fonts::Weight::Bold),
|
||||
background: Some(background.into()),
|
||||
border_color: Some(theme.surface_3().into()),
|
||||
border_radius: Some(CornerRadius::with_all(warpui::elements::Radius::Pixels(8.))),
|
||||
border_width: Some(1.),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
pub fn new(title: String, body: Option<String>, styles: UiComponentStyles) -> Self {
|
||||
Self {
|
||||
title,
|
||||
body,
|
||||
child: None,
|
||||
styles,
|
||||
bottom_row: Default::default(),
|
||||
bottom_row_left: Default::default(),
|
||||
close_button: None,
|
||||
show_separator: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_child(mut self, child: Box<dyn Element>) -> Self {
|
||||
self.child = Some(child);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_close_button(mut self, close_button: Box<dyn Element>) -> Self {
|
||||
self.close_button = Some(close_button);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bottom_row_child(mut self, child: Box<dyn Element>) -> Self {
|
||||
self.bottom_row.push(child);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bottom_row_left_child(mut self, child: Box<dyn Element>) -> Self {
|
||||
self.bottom_row_left.push(child);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_width(mut self, width: f32) -> Self {
|
||||
self.styles.width = Some(width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_separator(mut self) -> Self {
|
||||
self.show_separator = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl UiComponent for Dialog {
|
||||
type ElementType = Dismiss;
|
||||
|
||||
fn build(self) -> Dismiss {
|
||||
let mut header = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Text::new(
|
||||
self.title,
|
||||
self.styles.font_family_id.expect("FamilyId set"),
|
||||
self.styles.font_size.expect("Font size set"),
|
||||
)
|
||||
.with_style(self.styles.font_properties())
|
||||
.with_color(self.styles.font_color.unwrap_or_default())
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(close_button) = self.close_button {
|
||||
header.add_child(close_button);
|
||||
}
|
||||
|
||||
let footer = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children(self.bottom_row_left)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children(self.bottom_row)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let (left_padding, top_padding, right_padding, bottom_padding) =
|
||||
if let Some(custom_padding) = self.styles.padding {
|
||||
(
|
||||
custom_padding.left,
|
||||
custom_padding.top,
|
||||
custom_padding.right,
|
||||
custom_padding.bottom,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
DIALOG_PADDING,
|
||||
DIALOG_PADDING,
|
||||
DIALOG_PADDING,
|
||||
DIALOG_PADDING,
|
||||
)
|
||||
};
|
||||
|
||||
let mut main_content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(
|
||||
Container::new(header.finish())
|
||||
.with_padding_bottom(DIALOG_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(body) = self.body {
|
||||
main_content.add_child(
|
||||
Container::new(
|
||||
Text::new(body, self.styles.font_family_id.expect("FamilyId set"), 14.)
|
||||
.with_style(Properties {
|
||||
style: self.styles.font_properties().style,
|
||||
weight: Weight::Thin,
|
||||
})
|
||||
.with_color(self.styles.font_color.unwrap_or_default())
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(DIALOG_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(child) = self.child {
|
||||
main_content = main_content.with_child(
|
||||
Container::new(child)
|
||||
.with_padding_bottom(DIALOG_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let padded_main_content = Container::new(main_content.finish())
|
||||
.with_padding_left(left_padding)
|
||||
.with_padding_top(top_padding)
|
||||
.with_padding_right(right_padding)
|
||||
.finish();
|
||||
|
||||
let footer_container = if self.show_separator {
|
||||
let border_color = self.styles.border_color.unwrap_or_default();
|
||||
|
||||
Container::new(footer)
|
||||
.with_padding_left(left_padding)
|
||||
.with_padding_right(right_padding)
|
||||
.with_padding_top(bottom_padding)
|
||||
.with_border(Border::top(1.).with_border_fill(border_color))
|
||||
.finish()
|
||||
} else {
|
||||
Container::new(footer)
|
||||
.with_padding_left(left_padding)
|
||||
.with_padding_right(right_padding)
|
||||
.with_padding_top(0.)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let flex_column_contents = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(padded_main_content)
|
||||
.with_child(footer_container)
|
||||
.finish();
|
||||
|
||||
let mut dialog = Container::new(flex_column_contents).with_padding_bottom(bottom_padding);
|
||||
|
||||
if let Some(background) = self.styles.background {
|
||||
dialog = dialog.with_background(background);
|
||||
}
|
||||
if let Some(border_radius) = self.styles.border_radius {
|
||||
dialog = dialog.with_corner_radius(border_radius);
|
||||
}
|
||||
if let Some(border_fill) = self.styles.border_color {
|
||||
let border = Border::all(self.styles.border_width.unwrap_or_default())
|
||||
.with_border_fill(border_fill);
|
||||
dialog = dialog.with_border(border);
|
||||
}
|
||||
|
||||
let mut dialog_box = ConstrainedBox::new(dialog.finish());
|
||||
|
||||
if let Some(width) = self.styles.width {
|
||||
dialog_box = dialog_box.with_width(width);
|
||||
}
|
||||
|
||||
Dismiss::new(dialog_box.finish())
|
||||
}
|
||||
|
||||
fn with_style(self, styles: UiComponentStyles) -> Self {
|
||||
Self { styles, ..self }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::icons::Icon as WarpIcon;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::{Fill as WarpThemeFill, WarpTheme};
|
||||
use warpui::elements::{
|
||||
ChildAnchor, ConstrainedBox, Container, CornerRadius, Element, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack,
|
||||
};
|
||||
|
||||
use crate::ai::agent::conversation::ConversationStatus;
|
||||
use crate::terminal::CLIAgent;
|
||||
use crate::themes::theme::Fill as ThemeFill;
|
||||
|
||||
/// Sizing configuration for the icon circle and its status badge.
|
||||
pub(crate) struct IconWithStatusSizing {
|
||||
pub(crate) icon_size: f32,
|
||||
pub(crate) padding: f32,
|
||||
pub(crate) badge_icon_size: f32,
|
||||
pub(crate) badge_padding: f32,
|
||||
/// The overall constrained size for the stack.
|
||||
/// When set, overrides the default `icon_size + padding * 2`.
|
||||
pub(crate) overall_size_override: Option<f32>,
|
||||
/// Offset of the status badge from the bottom-right corner of the circle.
|
||||
/// Positive x pushes right, positive y pushes down.
|
||||
pub(crate) badge_offset: (f32, f32),
|
||||
}
|
||||
|
||||
/// What to render inside the circle.
|
||||
pub(crate) enum IconWithStatusVariant {
|
||||
/// A generic icon with a given color on an overlay background.
|
||||
Neutral {
|
||||
icon: WarpIcon,
|
||||
icon_color: WarpThemeFill,
|
||||
},
|
||||
/// A pre-built icon element on an overlay background.
|
||||
NeutralElement { icon_element: Box<dyn Element> },
|
||||
/// An Oz agent icon on the theme background.
|
||||
OzAgent {
|
||||
status: Option<ConversationStatus>,
|
||||
is_ambient: bool,
|
||||
},
|
||||
/// A CLI agent icon on the agent's brand color background.
|
||||
CLIAgent {
|
||||
agent: CLIAgent,
|
||||
status: Option<ConversationStatus>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Renders an icon inside a circle with an optional status badge overlay.
|
||||
pub(crate) fn render_icon_with_status(
|
||||
variant: IconWithStatusVariant,
|
||||
sizing: &IconWithStatusSizing,
|
||||
theme: &WarpTheme,
|
||||
badge_ring_background: WarpThemeFill,
|
||||
) -> Box<dyn Element> {
|
||||
let sub_text = theme.sub_text_color(theme.background());
|
||||
|
||||
match variant {
|
||||
IconWithStatusVariant::Neutral { icon, icon_color } => {
|
||||
let inner = ConstrainedBox::new(icon.to_warpui_icon(icon_color).finish())
|
||||
.with_width(sizing.icon_size)
|
||||
.with_height(sizing.icon_size)
|
||||
.finish();
|
||||
Container::new(inner)
|
||||
.with_uniform_padding(sizing.padding)
|
||||
.with_background(internal_colors::fg_overlay_2(theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
(sizing.icon_size + sizing.padding * 2.) / 2.,
|
||||
)))
|
||||
.finish()
|
||||
}
|
||||
IconWithStatusVariant::NeutralElement { icon_element } => {
|
||||
let inner = ConstrainedBox::new(icon_element)
|
||||
.with_width(sizing.icon_size)
|
||||
.with_height(sizing.icon_size)
|
||||
.finish();
|
||||
Container::new(inner)
|
||||
.with_uniform_padding(sizing.padding)
|
||||
.with_background(internal_colors::fg_overlay_2(theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
(sizing.icon_size + sizing.padding * 2.) / 2.,
|
||||
)))
|
||||
.finish()
|
||||
}
|
||||
IconWithStatusVariant::OzAgent { status, is_ambient } => {
|
||||
let icon = if is_ambient {
|
||||
WarpIcon::OzCloud
|
||||
} else {
|
||||
WarpIcon::Oz
|
||||
};
|
||||
let inner = ConstrainedBox::new(
|
||||
icon.to_warpui_icon(theme.main_text_color(theme.background()))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(sizing.icon_size)
|
||||
.with_height(sizing.icon_size)
|
||||
.finish();
|
||||
let circle = Container::new(inner)
|
||||
.with_uniform_padding(sizing.padding)
|
||||
.with_background(theme.background())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
(sizing.icon_size + sizing.padding * 2.) / 2.,
|
||||
)))
|
||||
.finish();
|
||||
render_with_optional_status_badge(
|
||||
circle,
|
||||
status.as_ref(),
|
||||
sizing,
|
||||
theme,
|
||||
badge_ring_background,
|
||||
)
|
||||
}
|
||||
IconWithStatusVariant::CLIAgent { agent, status } => {
|
||||
let brand_color = agent
|
||||
.brand_color()
|
||||
.unwrap_or(ColorU::new(100, 100, 100, 255));
|
||||
let icon_color = agent.brand_icon_color();
|
||||
let icon_element = agent
|
||||
.icon()
|
||||
.map(|icon| {
|
||||
icon.to_warpui_icon(WarpThemeFill::Solid(icon_color))
|
||||
.finish()
|
||||
})
|
||||
.unwrap_or_else(|| WarpIcon::Terminal.to_warpui_icon(sub_text).finish());
|
||||
let inner = ConstrainedBox::new(icon_element)
|
||||
.with_width(sizing.icon_size)
|
||||
.with_height(sizing.icon_size)
|
||||
.finish();
|
||||
let circle = Container::new(inner)
|
||||
.with_uniform_padding(sizing.padding)
|
||||
.with_background(ThemeFill::Solid(brand_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
(sizing.icon_size + sizing.padding * 2.) / 2.,
|
||||
)))
|
||||
.finish();
|
||||
render_with_optional_status_badge(
|
||||
circle,
|
||||
status.as_ref(),
|
||||
sizing,
|
||||
theme,
|
||||
badge_ring_background,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a status badge with a cutout ring to the bottom-right of the circle.
|
||||
fn render_with_optional_status_badge(
|
||||
circle: Box<dyn Element>,
|
||||
status: Option<&ConversationStatus>,
|
||||
sizing: &IconWithStatusSizing,
|
||||
theme: &WarpTheme,
|
||||
badge_ring_background: WarpThemeFill,
|
||||
) -> Box<dyn Element> {
|
||||
let Some(status) = status else {
|
||||
return circle;
|
||||
};
|
||||
let (icon, color) = status.status_icon_and_color(theme);
|
||||
let badge_icon = ConstrainedBox::new(icon.to_warpui_icon(WarpThemeFill::Solid(color)).finish())
|
||||
.with_width(sizing.badge_icon_size)
|
||||
.with_height(sizing.badge_icon_size)
|
||||
.finish();
|
||||
let badge = Container::new(badge_icon)
|
||||
.with_uniform_padding(sizing.badge_padding)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.finish();
|
||||
// Cutout ring that visually separates the badge from the circle.
|
||||
let badge_with_ring = Container::new(badge)
|
||||
.with_uniform_padding(sizing.badge_padding)
|
||||
.with_background(badge_ring_background)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.finish();
|
||||
|
||||
let circle_size = sizing.icon_size + sizing.padding * 2.;
|
||||
let overall_size = sizing.overall_size_override.unwrap_or(circle_size);
|
||||
let mut stack = Stack::new().with_child(
|
||||
ConstrainedBox::new(circle)
|
||||
.with_width(overall_size)
|
||||
.with_height(overall_size)
|
||||
.finish(),
|
||||
);
|
||||
stack.add_positioned_child(
|
||||
badge_with_ring,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(sizing.badge_offset.0, sizing.badge_offset.1),
|
||||
ParentOffsetBounds::ParentBySize,
|
||||
ParentAnchor::BottomRight,
|
||||
ChildAnchor::BottomRight,
|
||||
),
|
||||
);
|
||||
ConstrainedBox::new(stack.finish())
|
||||
.with_width(overall_size)
|
||||
.with_height(overall_size)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::{appearance::Appearance, ui_components::icons::Icon};
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{CornerRadius, MouseState, Radius};
|
||||
use warpui::Element;
|
||||
|
||||
/// Shared item highlight state for left-panel style lists (file tree, global search results,
|
||||
/// warp drive rows, etc.).
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ItemHighlightState {
|
||||
None,
|
||||
Selected,
|
||||
Hovered,
|
||||
}
|
||||
|
||||
impl ItemHighlightState {
|
||||
pub fn new(is_selected: bool, mouse_state: &MouseState) -> Self {
|
||||
if is_selected {
|
||||
ItemHighlightState::Selected
|
||||
} else if mouse_state.is_hovered() {
|
||||
ItemHighlightState::Hovered
|
||||
} else {
|
||||
ItemHighlightState::None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_and_icon_color(&self, appearance: &Appearance) -> ColorU {
|
||||
match self {
|
||||
ItemHighlightState::None => {
|
||||
blended_colors::text_sub(appearance.theme(), appearance.theme().background())
|
||||
}
|
||||
ItemHighlightState::Selected => appearance.theme().foreground().into(),
|
||||
ItemHighlightState::Hovered => {
|
||||
blended_colors::text_main(appearance.theme(), appearance.theme().background())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn background_color(&self, appearance: &Appearance) -> Option<Fill> {
|
||||
match self {
|
||||
ItemHighlightState::None => None,
|
||||
ItemHighlightState::Selected => Some(internal_colors::fg_overlay_4(appearance.theme())),
|
||||
ItemHighlightState::Hovered => Some(internal_colors::fg_overlay_2(appearance.theme())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn corner_radius(&self) -> Option<CornerRadius> {
|
||||
match self {
|
||||
ItemHighlightState::None => None,
|
||||
ItemHighlightState::Selected | ItemHighlightState::Hovered => {
|
||||
Some(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum ImageOrIcon {
|
||||
Icon(Icon),
|
||||
Image(Box<dyn Element>),
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use crate::appearance::Appearance;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use warpui::elements::{
|
||||
ChildAnchor, ChildView, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Stack,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, EventContext, View, ViewHandle};
|
||||
|
||||
use super::buttons::{highlight, icon_button};
|
||||
use super::icons::Icon;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum MenuDirection {
|
||||
Left, // Menu is left of the "..." button icon
|
||||
Right,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn icon_button_with_context_menu<F, V: View>(
|
||||
icon: Icon,
|
||||
on_click_action: F,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
context_menu: &ViewHandle<V>,
|
||||
is_menu_open: bool,
|
||||
menu_direction: MenuDirection,
|
||||
cursor: Option<Cursor>,
|
||||
style: Option<UiComponentStyles>,
|
||||
appearance: &Appearance,
|
||||
) -> Stack
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
let mut button = icon_button(appearance, icon, is_menu_open, mouse_state_handle);
|
||||
if let Some(style) = style {
|
||||
button = button.with_style(style);
|
||||
}
|
||||
|
||||
let mut button_with_menu = Stack::new().with_child(
|
||||
button
|
||||
.with_cursor(cursor)
|
||||
.build()
|
||||
.on_click(on_click_action)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if is_menu_open {
|
||||
button_with_menu.add_positioned_overlay_child(
|
||||
ChildView::new(context_menu).finish(),
|
||||
offset_positioning(menu_direction),
|
||||
);
|
||||
}
|
||||
|
||||
button_with_menu
|
||||
}
|
||||
|
||||
pub fn highlight_icon_button_with_context_menu<F, V: View>(
|
||||
icon: Icon,
|
||||
on_click_action: F,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
context_menu: &ViewHandle<V>,
|
||||
is_menu_open: bool,
|
||||
menu_direction: MenuDirection,
|
||||
appearance: &Appearance,
|
||||
) -> Stack
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
let mut button_with_menu = Stack::new().with_child(
|
||||
highlight(
|
||||
icon_button(appearance, icon, is_menu_open, mouse_state_handle),
|
||||
appearance,
|
||||
)
|
||||
.build()
|
||||
.on_click(on_click_action)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if is_menu_open {
|
||||
button_with_menu.add_positioned_overlay_child(
|
||||
ChildView::new(context_menu).finish(),
|
||||
offset_positioning(menu_direction),
|
||||
);
|
||||
}
|
||||
|
||||
button_with_menu
|
||||
}
|
||||
|
||||
/// Variant with surface_1 hover background for Warp Drive items
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn icon_button_with_context_menu_drive<F, V: View>(
|
||||
icon: Icon,
|
||||
on_click_action: F,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
context_menu: &ViewHandle<V>,
|
||||
is_menu_open: bool,
|
||||
menu_direction: MenuDirection,
|
||||
cursor: Option<Cursor>,
|
||||
appearance: &Appearance,
|
||||
) -> Stack
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
let button = icon_button(appearance, icon, is_menu_open, mouse_state_handle)
|
||||
.with_hovered_styles(
|
||||
warpui::ui_components::components::UiComponentStyles::default()
|
||||
.set_background(appearance.theme().surface_1().into())
|
||||
.set_border_color(appearance.theme().surface_3().into()),
|
||||
)
|
||||
.with_cursor(cursor);
|
||||
|
||||
let mut button_with_menu =
|
||||
Stack::new().with_child(button.build().on_click(on_click_action).finish());
|
||||
|
||||
if is_menu_open {
|
||||
button_with_menu.add_positioned_overlay_child(
|
||||
ChildView::new(context_menu).finish(),
|
||||
offset_positioning(menu_direction),
|
||||
);
|
||||
}
|
||||
|
||||
button_with_menu
|
||||
}
|
||||
|
||||
/// Variant with surface_1 hover background for Warp Drive items (highlighted)
|
||||
pub fn highlight_icon_button_with_context_menu_drive<F, V: View>(
|
||||
icon: Icon,
|
||||
on_click_action: F,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
context_menu: &ViewHandle<V>,
|
||||
is_menu_open: bool,
|
||||
menu_direction: MenuDirection,
|
||||
appearance: &Appearance,
|
||||
) -> Stack
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
let button = highlight(
|
||||
icon_button(appearance, icon, is_menu_open, mouse_state_handle),
|
||||
appearance,
|
||||
)
|
||||
.with_hovered_styles(
|
||||
warpui::ui_components::components::UiComponentStyles::default()
|
||||
.set_background(appearance.theme().surface_1().into())
|
||||
.set_border_color(appearance.theme().surface_3().into()),
|
||||
);
|
||||
|
||||
let mut button_with_menu =
|
||||
Stack::new().with_child(button.build().on_click(on_click_action).finish());
|
||||
|
||||
if is_menu_open {
|
||||
button_with_menu.add_positioned_overlay_child(
|
||||
ChildView::new(context_menu).finish(),
|
||||
offset_positioning(menu_direction),
|
||||
);
|
||||
}
|
||||
|
||||
button_with_menu
|
||||
}
|
||||
|
||||
fn offset_positioning(menu_direction: MenuDirection) -> OffsetPositioning {
|
||||
match menu_direction {
|
||||
MenuDirection::Left => OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
MenuDirection::Right => OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Warp UI Components module contains functions and structs that implement our internal components
|
||||
//! used for the apps design (our buttons with styling, headers and panels etc.) as well definition
|
||||
//! of colors (aka blended colors from the figma designs derived from Warp theme) and icons used
|
||||
//! within the app.
|
||||
pub(crate) mod avatar;
|
||||
pub(crate) mod blended_colors;
|
||||
pub(crate) mod breadcrumb;
|
||||
pub mod buttons;
|
||||
pub(crate) mod color_dot;
|
||||
pub(crate) mod dialog;
|
||||
pub(crate) mod icon_with_status;
|
||||
pub(crate) mod item_highlight;
|
||||
pub(crate) mod menu_button;
|
||||
pub(crate) mod red_notification_dot;
|
||||
pub(crate) mod render_file_search_row;
|
||||
pub mod tab_selector;
|
||||
pub(crate) mod window_focus_dimming;
|
||||
|
||||
pub use warp_core::ui::icons;
|
||||
|
||||
const BORDER_RADIUS: f32 = 4.;
|
||||
@@ -0,0 +1,76 @@
|
||||
use crate::appearance::Appearance;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::theme::AnsiColorIdentifier;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ChildAnchor, ConstrainedBox, Container, CornerRadius, Empty, Fill, OffsetPositioning,
|
||||
ParentAnchor, ParentElement as _, ParentOffsetBounds, Radius, Stack,
|
||||
},
|
||||
ui_components::components::UiComponentStyles,
|
||||
Element,
|
||||
};
|
||||
|
||||
pub struct RedNotificationDot {}
|
||||
|
||||
impl RedNotificationDot {
|
||||
fn render_internal(styles: &UiComponentStyles) -> Box<dyn Element> {
|
||||
let width = styles.width.expect("RedNotificationDot requires width");
|
||||
let height = styles.height.expect("RedNotificationDot requires height");
|
||||
|
||||
let status_constrained_box = ConstrainedBox::new(Empty::new().finish())
|
||||
.with_height(height)
|
||||
.with_width(width)
|
||||
.finish();
|
||||
|
||||
let mut status_element = Container::new(status_constrained_box);
|
||||
|
||||
if let Some(corner) = styles.border_radius {
|
||||
status_element = status_element.with_corner_radius(corner);
|
||||
}
|
||||
|
||||
if let Some(background) = styles.background {
|
||||
status_element = status_element.with_background(background);
|
||||
}
|
||||
status_element.finish()
|
||||
}
|
||||
|
||||
pub fn default_styles(appearance: &Appearance) -> UiComponentStyles {
|
||||
let diameter = 8.;
|
||||
UiComponentStyles {
|
||||
width: Some(diameter),
|
||||
height: Some(diameter),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
|
||||
background: Some(Fill::Solid(
|
||||
AnsiColorIdentifier::Red
|
||||
.to_ansi_color(&appearance.theme().terminal_colors().normal)
|
||||
.into(),
|
||||
)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_with_offset(
|
||||
element: Box<dyn Element>,
|
||||
styles: &UiComponentStyles,
|
||||
(x_delta, y_delta): (f32, f32),
|
||||
) -> Box<dyn Element> {
|
||||
let width = styles.width.expect("RedNotificationDot requires width");
|
||||
let height = styles.height.expect("RedNotificationDot requires height");
|
||||
|
||||
let x_axis_offset = width / 2.;
|
||||
let y_axis_offset = -(height / 2.);
|
||||
|
||||
let mut stack = Stack::new().with_child(element);
|
||||
|
||||
stack.add_positioned_child(
|
||||
RedNotificationDot::render_internal(styles),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(x_axis_offset + x_delta, y_axis_offset + y_delta),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
stack.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! File search row rendering components.
|
||||
//!
|
||||
//! This module provides UI components for rendering file and directory search results
|
||||
//! in search interfaces. It handles the display of file names with their parent paths,
|
||||
//! supports fuzzy match highlighting, and intelligently truncates long paths while
|
||||
//! preserving important information.
|
||||
//!
|
||||
//! The main functionality includes:
|
||||
//! - Rendering file/directory names with optional path context
|
||||
//! - Highlighting fuzzy match results in both filename and path portions
|
||||
//! - Smart truncation of long file paths with ellipsis
|
||||
//! - Responsive layout that adapts to different highlight states
|
||||
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use std::path::Path;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
Container, CrossAxisAlignment, Flex, Highlight, MainAxisSize, ParentElement, Shrinkable, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::ai_context_menu::safe_truncate;
|
||||
use crate::search::ItemHighlightState;
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
pub const MAX_COMBINED_LENGTH: usize = 55;
|
||||
|
||||
pub struct FileSearchRowOptions<'a> {
|
||||
pub match_result: Option<&'a FuzzyMatchResult>,
|
||||
pub highlight_state: ItemHighlightState,
|
||||
pub item_font_size: Option<f32>,
|
||||
pub path_font_size: Option<f32>,
|
||||
pub item_text_fill_override: Option<Fill>,
|
||||
pub text_color_override: Option<Fill>,
|
||||
pub max_combined_length: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> Default for FileSearchRowOptions<'a> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
match_result: None,
|
||||
highlight_state: ItemHighlightState::Default,
|
||||
item_font_size: None,
|
||||
path_font_size: None,
|
||||
item_text_fill_override: None,
|
||||
text_color_override: None,
|
||||
max_combined_length: Some(MAX_COMBINED_LENGTH),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a file search result row containing a file/directory name with optional path context.
|
||||
///
|
||||
/// This function creates a UI element that displays a file or directory name along with its parent
|
||||
/// path, with support for fuzzy match highlighting and intelligent truncation. The layout adapts
|
||||
/// based on the highlight state and prioritizes showing the filename over the full path when space
|
||||
/// is limited.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - The full path to the file or directory
|
||||
/// * `options` - Rendering options (match result, highlight state, font sizes, etc.)
|
||||
/// * `app` - Application context for accessing themes and fonts
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A boxed UI element representing the file search row
|
||||
pub fn render_file_search_row(
|
||||
path: &Path,
|
||||
options: FileSearchRowOptions<'_>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let FileSearchRowOptions {
|
||||
match_result,
|
||||
highlight_state,
|
||||
item_font_size,
|
||||
path_font_size,
|
||||
item_text_fill_override,
|
||||
text_color_override,
|
||||
max_combined_length,
|
||||
} = options;
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
// Extract item name from path (file or directory name)
|
||||
let max_combined_length = max_combined_length.unwrap_or(MAX_COMBINED_LENGTH);
|
||||
|
||||
let original_item_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let mut item_name = original_item_name.clone();
|
||||
|
||||
// Create path display (show it grayed out)
|
||||
let original_path_display = path
|
||||
.parent()
|
||||
.and_then(|parent| parent.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let mut path_display = original_path_display.clone();
|
||||
|
||||
// Track if we truncated anything for highlight adjustment
|
||||
let mut filename_truncated = false;
|
||||
let mut path_truncated = false;
|
||||
let mut path_truncation_offset = 0;
|
||||
|
||||
// Ensure combined length is less than MAX_COMBINED_LENGTH characters
|
||||
if options.max_combined_length.is_some() {
|
||||
let combined_length = item_name.len() + path_display.len();
|
||||
|
||||
if combined_length > max_combined_length {
|
||||
if item_name.len() >= max_combined_length {
|
||||
safe_truncate(&mut item_name, max_combined_length - 3);
|
||||
item_name.push_str("...");
|
||||
filename_truncated = true;
|
||||
path_display.clear();
|
||||
} else {
|
||||
let available_for_path = max_combined_length - item_name.len();
|
||||
if path_display.len() > available_for_path {
|
||||
let new_path_len = available_for_path.saturating_sub(3);
|
||||
path_truncation_offset = path_display.len() - new_path_len;
|
||||
safe_truncate(&mut path_display, new_path_len);
|
||||
path_display.push_str("...");
|
||||
path_truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate highlight indices for item name and path
|
||||
let (item_name_highlights, path_highlights) = if let Some(match_result) = match_result {
|
||||
let full_path_str = path.to_string_lossy();
|
||||
let item_name_start_in_full_path = full_path_str.len() - original_item_name.len();
|
||||
calculate_highlight_indices(
|
||||
match_result,
|
||||
&original_path_display,
|
||||
item_name_start_in_full_path,
|
||||
filename_truncated,
|
||||
path_truncated,
|
||||
path_truncation_offset,
|
||||
max_combined_length,
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
let item_font_size: f32 =
|
||||
item_font_size.unwrap_or_else(|| appearance.monospace_font_size() - 1.0);
|
||||
let path_font_size: f32 =
|
||||
path_font_size.unwrap_or_else(|| appearance.monospace_font_size() - 2.0);
|
||||
|
||||
let base_item_fill: Fill =
|
||||
item_text_fill_override.unwrap_or_else(|| highlight_state.main_text_fill(appearance));
|
||||
let base_path_fill: Fill = highlight_state.sub_text_fill(appearance);
|
||||
|
||||
let base_item_color = base_item_fill.into_solid();
|
||||
let base_path_color = base_path_fill.into_solid();
|
||||
|
||||
let (item_color, path_color) = if let Some(override_fill) = text_color_override {
|
||||
let override_color = override_fill.into_solid();
|
||||
(override_color, override_color)
|
||||
} else {
|
||||
(base_item_color, base_path_color)
|
||||
};
|
||||
|
||||
// Create item name with match highlighting
|
||||
let mut item_text = Text::new_inline(item_name, appearance.ui_font_family(), item_font_size)
|
||||
.with_color(item_color)
|
||||
.soft_wrap(false);
|
||||
|
||||
if !item_name_highlights.is_empty() {
|
||||
item_text = item_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
item_name_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
// Create path text with lighter color and highlights
|
||||
let path_text = if !path_display.is_empty() {
|
||||
let mut path_text =
|
||||
Text::new_inline(path_display, appearance.ui_font_family(), path_font_size)
|
||||
.with_color(path_color)
|
||||
.with_clip(ClipConfig::start())
|
||||
.soft_wrap(false);
|
||||
|
||||
if !path_highlights.is_empty() {
|
||||
path_text = path_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
path_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
Some(path_text)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create row with item name and path
|
||||
let mut row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
row.add_child(
|
||||
Shrinkable::new(
|
||||
// setting this to a high value so that we don't shrink the item text until absolutely necessary
|
||||
20.0,
|
||||
item_text.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(path_text) = path_text {
|
||||
row.add_child(
|
||||
Shrinkable::new(
|
||||
1.0,
|
||||
Container::new(path_text.finish())
|
||||
.with_padding_left(3.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
/// Calculates highlight indices for both the item name and path portions of a file search result.
|
||||
///
|
||||
/// This function takes fuzzy match indices from the full path and splits them appropriately
|
||||
/// between the filename and directory path components. It handles truncation adjustments
|
||||
/// to ensure highlights remain accurate when text is shortened with ellipsis.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `match_result` - The fuzzy match result containing highlight indices for the full path
|
||||
/// * `original_path` - The original (untruncated) directory path string
|
||||
/// * `item_name_start_in_full_path` - Byte offset where the filename begins in the full path
|
||||
/// * `item_name_truncated` - Whether the filename was truncated with ellipsis
|
||||
/// * `path_truncated` - Whether the directory path was truncated with ellipsis
|
||||
/// * `path_truncation_offset` - Number of characters removed from the start of the path
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A tuple containing:
|
||||
/// - `Vec<usize>` - Highlight indices for the item name portion
|
||||
/// - `Vec<usize>` - Highlight indices for the directory path portion
|
||||
fn calculate_highlight_indices(
|
||||
match_result: &FuzzyMatchResult,
|
||||
original_path: &str,
|
||||
item_name_start_in_full_path: usize,
|
||||
item_name_truncated: bool,
|
||||
path_truncated: bool,
|
||||
path_truncation_offset: usize,
|
||||
max_combined_length: usize,
|
||||
) -> (Vec<usize>, Vec<usize>) {
|
||||
let mut item_name_highlights = Vec::new();
|
||||
let mut path_highlights = Vec::new();
|
||||
|
||||
for &index in &match_result.matched_indices {
|
||||
if index >= item_name_start_in_full_path {
|
||||
// This highlight is in the item name
|
||||
let item_name_index = index - item_name_start_in_full_path;
|
||||
|
||||
// Only include if within the displayed item name range
|
||||
if !item_name_truncated || item_name_index < (max_combined_length - 3) {
|
||||
item_name_highlights.push(item_name_index);
|
||||
}
|
||||
} else {
|
||||
// This highlight is in the path
|
||||
let path_index = index;
|
||||
|
||||
// Adjust for path truncation
|
||||
if path_truncated {
|
||||
if path_index >= path_truncation_offset {
|
||||
let adjusted_index = path_index - path_truncation_offset;
|
||||
if adjusted_index < original_path.len().saturating_sub(3) {
|
||||
path_highlights.push(adjusted_index);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No truncation, use index as-is
|
||||
if path_index < original_path.len() {
|
||||
path_highlights.push(path_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(item_name_highlights, path_highlights)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, Container, CrossAxisAlignment, Element, Empty, Fill, Flex, MouseStateHandle,
|
||||
ParentElement,
|
||||
},
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
pub struct SettingsTab {
|
||||
pub label: String,
|
||||
pub mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl SettingsTab {
|
||||
pub fn new(label: impl Into<String>, mouse_state: MouseStateHandle) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
mouse_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a tab selector with a row of tabs and a bottom border indicator.
|
||||
pub fn render_tab_selector<F>(
|
||||
tabs: Vec<SettingsTab>,
|
||||
selected_label: &str,
|
||||
on_select: F,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
// The on select function will take in the click event context and the selected label,
|
||||
// and will then presumably change the passed in selected label.
|
||||
F: Fn(&str, &mut warpui::EventContext) + 'static + Clone,
|
||||
{
|
||||
let mut tabs_row = Flex::row()
|
||||
.with_spacing(12.)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::End);
|
||||
|
||||
for tab in tabs {
|
||||
let is_selected = tab.label == selected_label;
|
||||
|
||||
let tab_button_styles = UiComponentStyles {
|
||||
font_color: Some(if is_selected {
|
||||
appearance.theme().active_ui_text_color().into()
|
||||
} else {
|
||||
blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1())
|
||||
}),
|
||||
font_size: Some(16.),
|
||||
padding: Some(Coords {
|
||||
top: 4.,
|
||||
bottom: 4.,
|
||||
left: 0.,
|
||||
right: 0.,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let on_select_clone = on_select.clone();
|
||||
let button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Link, tab.mouse_state)
|
||||
.with_style(tab_button_styles)
|
||||
.with_text_label(tab.label.clone())
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
on_select_clone(&tab.label, ctx);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let tab_with_border = Container::new(button)
|
||||
.with_border(Border::bottom(2.).with_border_fill(if is_selected {
|
||||
Fill::Solid(appearance.theme().accent().into_solid())
|
||||
} else {
|
||||
Fill::None
|
||||
}))
|
||||
.finish();
|
||||
|
||||
tabs_row = tabs_row.with_child(tab_with_border);
|
||||
}
|
||||
|
||||
let separator = Container::new(Empty::new().finish())
|
||||
.with_border(Border::bottom(2.).with_border_fill(appearance.theme().outline()))
|
||||
.with_margin_top(-2.);
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_child(tabs_row.finish())
|
||||
.with_child(separator.finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(16.)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use warp_core::ui::color::coloru_with_opacity;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Element, Fill, Hoverable, MouseStateHandle, ParentElement, Rect, Stack,
|
||||
};
|
||||
use warpui::windowing::WindowManager;
|
||||
use warpui::{AppContext, SingletonEntity, WindowId};
|
||||
|
||||
use crate::window_settings::WindowSettings;
|
||||
use crate::workspace::panel_header_corner_radius;
|
||||
|
||||
/// Opacity level for dimming the header of unfocused windows.
|
||||
/// 0 means no dimming, 100 means 100% cover the top bar.
|
||||
const UNFOCUSED_WINDOW_DIMMING_OPACITY: crate::util::color::Opacity = 45;
|
||||
|
||||
/// Utility functions for applying consistent window focus dimming across all UI components.
|
||||
pub struct WindowFocusDimming;
|
||||
|
||||
impl WindowFocusDimming {
|
||||
/// Returns true if the specified window is currently focused.
|
||||
///
|
||||
/// On mobile WASM, this always returns true because mobile browsers don't have
|
||||
/// the traditional concept of "unfocused windows", and focus events can be
|
||||
/// unreliable due to soft keyboard management.
|
||||
pub fn is_window_focused(window_id: WindowId, ctx: &AppContext) -> bool {
|
||||
#[cfg(target_family = "wasm")]
|
||||
if warpui::platform::wasm::is_mobile_device() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let window_manager = WindowManager::as_ref(ctx);
|
||||
if !window_manager.app_is_active() {
|
||||
return false;
|
||||
}
|
||||
|
||||
window_manager.active_window() == Some(window_id)
|
||||
}
|
||||
|
||||
/// Applies dimming overlay for headers and top bar areas.
|
||||
/// Takes height and background color parameters for maximum flexibility.
|
||||
pub fn apply_panel_header_dimming(
|
||||
element: Box<dyn Element>,
|
||||
mouse_state: MouseStateHandle,
|
||||
height: f32,
|
||||
background_color: warpui::color::ColorU,
|
||||
window_id: WindowId,
|
||||
ctx: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
if !Self::is_window_focused(window_id, ctx) {
|
||||
let background_opacity = WindowSettings::as_ref(ctx)
|
||||
.background_opacity
|
||||
.effective_opacity(window_id, ctx);
|
||||
let scaled_opacity =
|
||||
(UNFOCUSED_WINDOW_DIMMING_OPACITY as f32 * background_opacity as f32 / 100.) as u8;
|
||||
let mut stack = Stack::new().with_child(element);
|
||||
let dimming_overlay = Rect::new()
|
||||
.with_background(Fill::Solid(coloru_with_opacity(
|
||||
background_color,
|
||||
scaled_opacity,
|
||||
)))
|
||||
.with_corner_radius(panel_header_corner_radius())
|
||||
.finish();
|
||||
stack.add_child(
|
||||
ConstrainedBox::new(dimming_overlay)
|
||||
.with_height(height)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Wrap the dimmed content in a hoverable that can clear dimming
|
||||
// if the window becomes active during hover (failsafe mechanism)
|
||||
Hoverable::new(mouse_state, |_| stack.finish())
|
||||
.on_hover(move |is_hovered, ctx, app, _position| {
|
||||
if is_hovered {
|
||||
// Double-check if window became active during hover
|
||||
// If so, trigger a re-render to clear the dimming
|
||||
if Self::is_window_focused(window_id, app) {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
element
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user