diff --git a/app/src/ui_components/icon_with_status.rs b/app/src/ui_components/icon_with_status.rs index a7e918a6c0..0f3f45eff3 100644 --- a/app/src/ui_components/icon_with_status.rs +++ b/app/src/ui_components/icon_with_status.rs @@ -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}; @@ -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, total_size: f32) -> Box { + 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 @@ -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( @@ -444,15 +451,9 @@ fn render_with_optional_status_badge( status_container_background: WarpThemeFill, ) -> Box { 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); @@ -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( diff --git a/app/src/ui_components/icon_with_status_tests.rs b/app/src/ui_components/icon_with_status_tests.rs index 6b962dfac6..27c8146661 100644 --- a/app/src/ui_components/icon_with_status_tests.rs +++ b/app/src/ui_components/icon_with_status_tests.rs @@ -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] @@ -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 + } + } + + impl Entity for AgentIconTestView { + type Event = (); + } + + impl View for AgentIconTestView { + fn ui_name() -> &'static str { + "AgentIconTestView" + } + + fn render(&self, _app: &AppContext) -> Box { + 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:?}" + ); + }); + }); +}