Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+257
View File
@@ -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()
}
}
+1
View File
@@ -0,0 +1 @@
pub use warp_core::ui::theme::color::internal_colors::*;
+130
View File
@@ -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()
}
+250
View File
@@ -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()),
)
}
+112
View File
@@ -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()
}
+240
View File
@@ -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 }
}
}
+196
View File
@@ -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()
}
+62
View File
@@ -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>),
}
+177
View File
@@ -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,
),
}
}
+21
View File
@@ -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)
}
+99
View File
@@ -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
}
}
}