Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 19 additions & 23 deletions app/src/ui_components/icon_with_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use warp_core::ui::icons::Icon as WarpIcon;
use warp_core::ui::theme::color::internal_colors;
use warp_core::ui::theme::{ColorScheme, Fill as WarpThemeFill, WarpTheme};
use warpui::elements::{
ChildAnchor, ConstrainedBox, Container, CornerRadius, Element, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack,
Align, ChildAnchor, ConstrainedBox, Container, CornerRadius, Element, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack,
};

use crate::ai::agent::conversation::{ConversationStatus, StatusColorStyle};
Expand Down Expand Up @@ -308,6 +308,18 @@ fn render_circle(
.finish()
}

/// Reserves the caller's full `total_size` square for `circle` and centers it inside.
/// `Stack` and `ConstrainedBox` paint children at the origin rather than centering, so
/// without the `Align` the circle — only `circle_size(total)` wide — sits flush against
/// the box's top-left, and the corner overlay (anchored to the box's bottom-right by
/// `corner_overlay_offset`) lands clear of the circle instead of tucked into its edge.
fn circle_centered_in_box(circle: Box<dyn Element>, total_size: f32) -> Box<dyn Element> {
ConstrainedBox::new(Align::new(circle).finish())
.with_width(total_size)
.with_height(total_size)
.finish()
}

/// Builds the neutral circle: a full-`total_size` container with the glyph at
/// `NEUTRAL_GLYPH_RATIO * total_size`. Used for non-agent surfaces (plain terminal,
/// code, file tabs, etc.) which have no status overlay and therefore should fill the
Expand Down Expand Up @@ -412,12 +424,7 @@ fn render_with_cloud_status_badge(
};

let cloud_offset = corner_overlay_offset(total_size, overlay_extra_overhang_ratio);
let mut stack = Stack::new().with_child(
ConstrainedBox::new(circle)
.with_width(total_size)
.with_height(total_size)
.finish(),
);
let mut stack = Stack::new().with_child(circle_centered_in_box(circle, total_size));
stack.add_positioned_child(
cloud_with_status,
OffsetPositioning::offset_from_parent(
Expand All @@ -444,15 +451,9 @@ fn render_with_optional_status_badge(
status_container_background: WarpThemeFill,
) -> Box<dyn Element> {
let Some(status) = status else {
// No status badge: still reserve the full `total_size` footprint the caller
// asked for, so badged and un-badged variants occupy identical space.
// `ConstrainedBox` only tightens constraints — it does not center — so the
// circle (which is only `circle_size(total)` wide) is painted at the box's
// top-left. Callers that need it centered must wrap it in `Align`.
return ConstrainedBox::new(circle)
.with_width(total_size)
.with_height(total_size)
.finish();
// No status badge: still occupy the full `total_size` footprint so badged and
// un-badged variants take identical space in their caller's layout.
return circle_centered_in_box(circle, total_size);
};
let (icon, color) = status.status_icon_and_color(theme, StatusColorStyle::Standard);
let badge_icon_diameter = badge_icon_size(total_size, badge_style);
Expand All @@ -477,12 +478,7 @@ fn render_with_optional_status_badge(
.finish();

let badge_corner_offset = corner_overlay_offset(total_size, overlay_extra_overhang_ratio);
let mut stack = Stack::new().with_child(
ConstrainedBox::new(circle)
.with_width(total_size)
.with_height(total_size)
.finish(),
);
let mut stack = Stack::new().with_child(circle_centered_in_box(circle, total_size));
stack.add_positioned_child(
badge_with_ring,
OffsetPositioning::offset_from_parent(
Expand Down
104 changes: 103 additions & 1 deletion app/src/ui_components/icon_with_status_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use warp_core::ui::theme::Fill;

use super::{OZ_AMBIENT_BACKGROUND_COLOR, warp_agent_circle_colors};
use super::{
CIRCLE_RATIO, IconWithStatusVariant, OZ_AMBIENT_BACKGROUND_COLOR, circle_size,
render_icon_with_status, warp_agent_circle_colors,
};
use crate::themes::default_themes::{dark_theme, light_theme};

#[test]
Expand All @@ -26,3 +29,102 @@ fn ambient_warp_agent_circle_keeps_purple_background_in_all_themes() {
assert_eq!(warp_agent_circle_colors(&dark_theme(), true), expected);
assert_eq!(warp_agent_circle_colors(&light_theme(), true), expected);
}

/// The brand circle covers only `CIRCLE_RATIO` of the footprint the component reserves, and
/// `corner_overlay_offset` positions the status badge against that footprint's bottom-right
/// corner. The two only meet while the circle is centered: left-aligned, the circle pulls up and
/// to the left while the badge stays put, so the badge floats detached instead of tucking into the
/// circle's edge. The footprint itself is reserved either way, so this is what a caller sees.
#[test]
fn brand_circle_is_centered_in_the_reserved_footprint() {
use pathfinder_geometry::vector::vec2f;
use warpui::platform::WindowStyle;
use warpui::{
App, AppContext, Element, Entity, Presenter, TypedActionView, View, ViewContext,
WindowInvalidation,
};

use crate::ai::agent::conversation::ConversationStatus;

const TOTAL_SIZE: f32 = 24.;
const EPSILON: f32 = 0.01;

struct AgentIconTestView;

impl AgentIconTestView {
fn new(_ctx: &mut ViewContext<Self>) -> Self {
Self
}
}

impl Entity for AgentIconTestView {
type Event = ();
}

impl View for AgentIconTestView {
fn ui_name() -> &'static str {
"AgentIconTestView"
}

fn render(&self, _app: &AppContext) -> Box<dyn Element> {
let theme = dark_theme();
render_icon_with_status(
IconWithStatusVariant::OzAgent {
status: Some(ConversationStatus::Success),
is_ambient: false,
},
TOTAL_SIZE,
0.,
&theme,
theme.background(),
)
}
}

impl TypedActionView for AgentIconTestView {
type Action = ();
}

App::test((), |mut app| async move {
let (window_id, _view) = app.add_window(WindowStyle::NotStealFocus, AgentIconTestView::new);
let root_view_id = app
.root_view_id(window_id)
.expect("window should have a root view");

let mut presenter = Presenter::new(window_id);
let invalidation = WindowInvalidation {
updated: [root_view_id].into_iter().collect(),
..Default::default()
};

app.update(move |ctx| {
presenter.invalidate(invalidation, ctx);
let scene = presenter.build_scene(vec2f(400., 300.), 1., None, ctx);

let expected_diameter = circle_size(TOTAL_SIZE);
let expected_inset = TOTAL_SIZE * (1. - CIRCLE_RATIO) / 2.;
let circle_origins: Vec<_> = scene
.layers()
.flat_map(|layer| layer.rects.iter())
.filter(|rect| {
(rect.bounds.width() - expected_diameter).abs() < EPSILON
&& (rect.bounds.height() - expected_diameter).abs() < EPSILON
})
.map(|rect| rect.bounds.origin())
.collect();

let [circle_origin] = circle_origins.as_slice() else {
panic!(
"expected exactly one {expected_diameter}px brand circle in the scene, found \
{circle_origins:?}"
);
};
assert!(
(circle_origin.x() - expected_inset).abs() < EPSILON
&& (circle_origin.y() - expected_inset).abs() < EPSILON,
"brand circle should be centered in the {TOTAL_SIZE}px footprint at \
({expected_inset}, {expected_inset}), got {circle_origin:?}"
);
});
});
}