From 154f7f2b4667fd8f5816ea7f36dff98cd0e37043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 00:38:30 -0400 Subject: [PATCH] =?UTF-8?q?Data=20display=20=C2=B7=20a=20tag=20stays=20ins?= =?UTF-8?q?ide=20its=20container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Tag is capped at its container's width, and its label ends in an ellipsis beside a fixed remove mark, so a long name in a narrow tag field stays inside the field instead of running past it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/data_display/badge.rs | 17 ++++++++++++----- src/forms/tags.rs | 2 ++ src/forms/tests/tags.rs | 30 ++++++++++++++++++++++++++++-- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0f5da54..1bf916a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,7 +119,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Paths with `/` between folders become a tree through `lists::Folder::of`, for `FileTree` and `files::ArchiveViewer` alike. - A folder's path over its entries is `lists::path_bar`, and its first order is `lists::listed`, folders first by name: DirectoryListing and FileExplorer share both, so every view shows one path and one order. - A file's icon comes from `primitives::file_icon`, one map from name to icon for every chapter; `files::FileIcon` draws it. What fits across a width comes from `layout::columns_for`, and `layout::fit` adds the inset that centers it. -- Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. Avatars take square pictures. +- Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. Avatars take square pictures. A tag is never wider than its container; a long label ends in an ellipsis beside its remove mark. - A picture drawn in code reaches gpui as a `RenderImage`. A host that replaces one frees the old through `App::drop_image`, or every frame stays in the sprite atlas (the gallery's model). - `img()` keeps loading state, and moves an animated picture's frames, only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping. - gpui reads a `&str` picture source as a web address. A file goes through `Path` (`documents::source` in the crate, `Path::new` in the gallery). diff --git a/src/data_display/badge.rs b/src/data_display/badge.rs index 8312587..1ed27cb 100644 --- a/src/data_display/badge.rs +++ b/src/data_display/badge.rs @@ -9,7 +9,7 @@ use gpui::{ use crate::{ primitives::{Icon, IconName, Severity}, theme::{ActiveTheme, ControlSize, IconSize, Palette, Radius, TextSize}, - typography::AnimatedNumber, + typography::{AnimatedNumber, Ellipsis}, }; /// How a badge or a tag is tinted: quiet, the accent, or a severity. @@ -246,7 +246,7 @@ impl RenderOnce for DotBadge { type OnRemove = Rc; -/// A short label for a kind, a topic or a filter: an optional icon, the label, and an x when it can be removed. +/// A short label for a kind, a topic or a filter: an optional icon, the label, and an x when it can be removed. It is never wider than its container; a long label ends in an ellipsis. #[derive(IntoElement)] pub struct Tag { id: ElementId, @@ -292,10 +292,12 @@ impl RenderOnce for Tag { Tone::Neutral => (colors.hover, colors.fg), other => other.colors(colors), }; - let remove = self.id.clone(); + let (remove, named) = (self.id.clone(), self.id.clone()); div() + .debug_selector(move || format!("tag {named}")) .flex() .flex_none() + .max_w_full() .items_center() .gap_1() .pl_2() @@ -307,13 +309,18 @@ impl RenderOnce for Tag { .text_size(theme.text_size(TextSize::Sm)) .text_color(text) .when_some(self.icon, |tag, icon| { - tag.child(Icon::new(icon).size(IconSize::Xs).color(text)) + tag.child( + div() + .flex_none() + .child(Icon::new(icon).size(IconSize::Xs).color(text)), + ) }) - .child(self.label) + .child(div().flex_1().min_w_0().child(Ellipsis::new(self.label))) .when_some(self.on_remove, |tag, remove_it| { tag.child( div() .id(remove) + .flex_none() .rounded(theme.radius(Radius::Sm)) .cursor_pointer() .hover(|style| style.bg(colors.active)) diff --git a/src/forms/tags.rs b/src/forms/tags.rs index 6a5c0cc..d5800cf 100644 --- a/src/forms/tags.rs +++ b/src/forms/tags.rs @@ -206,8 +206,10 @@ impl RenderOnce for TagInput { } .render(window, cx) }); + let field = self.id.clone(); div() .id(self.id) + .debug_selector(move || format!("tag-input {field}")) .relative() .flex() .flex_wrap() diff --git a/src/forms/tests/tags.rs b/src/forms/tests/tags.rs index 7d24b24..128d135 100644 --- a/src/forms/tests/tags.rs +++ b/src/forms/tests/tags.rs @@ -1,6 +1,6 @@ use gpui::{ - Context, Entity, IntoElement, KeyUpEvent, Keystroke, Render, SharedString, TestAppContext, - VisualTestContext, Window, + Context, Entity, IntoElement, KeyUpEvent, Keystroke, ParentElement, Render, SharedString, + Styled, TestAppContext, VisualTestContext, Window, div, px, }; use super::setup; @@ -109,3 +109,29 @@ fn escape_sets_the_suggestions_aside(cx: &mut TestAppContext) { press("enter", cx); assert_eq!(asked(&host, cx), [vec![SharedString::from("be")]]); } + +/// A narrow field holding one address whose name runs long. +struct Narrow; + +impl Render for Narrow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(200.0)) + .child( + TagInput::new("to", ["ana@atrium.studio"]).suggestions([Choice::new( + "ana@atrium.studio", + "Anastasia Lima-Fernández de la Torre, of the Atrium studio", + )]), + ) + } +} + +#[gpui::test] +fn a_long_chip_ends_inside_its_field(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Narrow); + settle(cx); + let chip = cx.debug_bounds("tag to-tag-0").expect("the chip draws"); + let field = cx.debug_bounds("tag-input to").expect("the field draws"); + assert!(chip.right() <= field.right(), "{chip:?} inside {field:?}"); +}