From 501d1b0f1a33349f02c32940a4fc0e2a55d5a79e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 19:51:43 -0400 Subject: [PATCH] Files: a tile's name keeps its ends; the ellipses share one line cutter gpui's line_clamp wraps once and lays the rest of a long name on the second line, which a centered tile then clipped at both ends. A tile's name is now one line, cut in the middle so the front and the extension stay. MiddleEllipsis draws like Ellipsis: in the text style around it, sized by its text, so a short name centers; the two share one cutter. --- AGENTS.md | 1 + TASKS.md | 1 + examples/gallery/pages/files/mod.rs | 5 ++ src/files/grid.rs | 9 +-- src/files/tests.rs | 28 ++++++++ src/typography/fit.rs | 102 +++++++++++++--------------- 6 files changed, 87 insertions(+), 59 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f47a965..5cd5889 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Ids inside a reusable component carry its owner's id or `EntityId`, animation ids too. Twin ids share focus, click and animation state. - Text wraps at the width its box had when measured. A cross-axis `max_w` comes too late, and a flex column counts wrapped text as one line. Put prose in a plain block inside a flex row with `flex_1().max_w(..)` (`feedback::states`). - gpui 0.2.2 keeps a no-wrap line's first measure, taken at full width inside flex and scroll boxes, so `.truncate()` clips there instead of ending in an ellipsis. A line that may overflow is a `typography::Ellipsis`. +- gpui 0.2.2's `line_clamp` wraps once and lays the rest on the last line, cut hard; centered, it loses both ends. A name whose ends matter is a `typography::MiddleEllipsis` on one line (`files::FileGrid`). - Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders. - gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box. - 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. diff --git a/TASKS.md b/TASKS.md index a4cd119..e4b4dcb 100644 --- a/TASKS.md +++ b/TASKS.md @@ -321,3 +321,4 @@ Per-component lines live in `tasks/`. Tags there: | T26d | 2 | PASS | | | T26e | 1 | FAIL | an option list had no width limit, so a long device name ran it past the window | | T26e | 2 | PASS | | +| T27a | 1 | FAIL | a tile's long name leaned on gpui's line_clamp, which cut its second line at both ends and lost the extension | diff --git a/examples/gallery/pages/files/mod.rs b/examples/gallery/pages/files/mod.rs index 5c81bb2..ccbfd61 100644 --- a/examples/gallery/pages/files/mod.rs +++ b/examples/gallery/pages/files/mod.rs @@ -44,6 +44,11 @@ fn entries() -> Vec { DirEntry::file("Walkthrough.mov", 184_000_000, ago(120)), DirEntry::file("main.rs", 9_200, ago(1)), DirEntry::file("Archive.zip", 48_000_000, ago(700)), + DirEntry::file( + "Quarterly review with the design team, final.pdf", + 820_000, + ago(6), + ), ] } diff --git a/src/files/grid.rs b/src/files/grid.rs index dc5e207..c729736 100644 --- a/src/files/grid.rs +++ b/src/files/grid.rs @@ -13,7 +13,7 @@ use crate::{ lists::DirEntry, primitives::{FocusRing, tab_stop}, theme::{ActiveTheme, IconSize, Radius, TextSize}, - typography::LEADING, + typography::{LEADING, MiddleEllipsis}, }; type OnEntry = Rc; @@ -157,13 +157,14 @@ impl RenderOnce for FileGrid { .child(FileIcon::entry(entry).size(IconSize::Xxl)) .child( div() + .debug_selector(|| format!("file-name {ix}")) .w_full() - .text_center() + .flex() + .justify_center() .text_size(theme.text_size(TextSize::Sm)) .line_height(relative(LEADING)) .text_color(colors.fg) - .line_clamp(2) - .child(entry.name().clone()), + .child(MiddleEllipsis::new(entry.name().clone())), ) .on_click(move |event, window, cx| { pick(ix, window, cx); diff --git a/src/files/tests.rs b/src/files/tests.rs index 1543da1..8926f69 100644 --- a/src/files/tests.rs +++ b/src/files/tests.rs @@ -156,3 +156,31 @@ fn a_name_is_listed_once() { fn the_pick_is_one_of_the_entries() { let _ = FileGrid::new("grid", [DirEntry::file("a", 1, then())]).selected("z"); } + +/// One tile whose name runs long. +struct Named; + +impl Render for Named { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let name = "A very long file name that keeps going past two whole lines.pdf"; + div() + .w(px(280.0)) + .child(FileGrid::new("grid", [DirEntry::file(name, 10, then())])) + } +} + +#[gpui::test] +fn a_long_name_takes_one_line_inside_its_tile(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Named); + settle(cx); + let (name, grid) = ( + cx.debug_bounds("file-name 0").expect("the name draws"), + cx.debug_bounds("file-grid").expect("the grid draws"), + ); + assert!(name.size.height < px(24.0), "one line: {name:?}"); + assert!( + name.left() >= grid.left() && name.right() <= grid.right(), + "{name:?} in {grid:?}" + ); +} diff --git a/src/typography/fit.rs b/src/typography/fit.rs index 36df19d..eb2da59 100644 --- a/src/typography/fit.rs +++ b/src/typography/fit.rs @@ -1,7 +1,7 @@ use gpui::{ - App, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, ShapedLine, - SharedString, StatefulInteractiveElement, Styled, TextRun, Window, canvas, div, prelude::*, - relative, transparent_black, + App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, + ShapedLine, SharedString, StatefulInteractiveElement, Styled, TextRun, Window, canvas, div, + prelude::*, relative, transparent_black, }; use crate::{ @@ -108,7 +108,33 @@ fn fit_end(text: &str, width: Pixels, window: &mut Window) -> String { cut(low) } -/// One line in the text style around it, cut at the end with an ellipsis when its box is too narrow. gpui 0.2.2 keeps a line's first measure, so its own `truncate` clips instead in flex boxes. +/// One line in the text style around it, cut by `fit` to its box. gpui 0.2.2 keeps a line's first measure, so its own `truncate` clips instead in flex boxes. +fn cut_line(text: SharedString, fit: fn(&str, Pixels, &mut Window) -> String) -> Div { + let line = text.clone(); + div() + .relative() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .child(div().text_color(transparent_black()).child(text)) + .child( + canvas( + move |bounds, window, _| shape(&fit(&line, bounds.size.width, window), window), + |bounds, shaped, window, cx| { + let line = window.text_style().line_height_in_pixels(window.rem_size()); + shaped + .paint(bounds.origin, line, window, cx) + .expect("a cut line paints"); + }, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) +} + +/// One line in the text style around it, cut at the end with an ellipsis when its box is too narrow. #[derive(IntoElement)] pub struct Ellipsis { text: SharedString, @@ -122,34 +148,11 @@ impl Ellipsis { impl RenderOnce for Ellipsis { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { - let text = self.text.clone(); - div() - .relative() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .child(div().text_color(transparent_black()).child(self.text)) - .child( - canvas( - move |bounds, window, _| { - shape(&fit_end(&text, bounds.size.width, window), window) - }, - |bounds, shaped, window, cx| { - let line = window.text_style().line_height_in_pixels(window.rem_size()); - shaped - .paint(bounds.origin, line, window, cx) - .expect("an ellipsis line paints"); - }, - ) - .absolute() - .top_0() - .left_0() - .size_full(), - ) + cut_line(self.text, fit_end) } } -/// Shortens in the middle, keeping both ends. Paths, hashes. +/// One line in the text style around it, cut in the middle when its box is too narrow, so both ends stay: paths, file names, hashes. #[derive(IntoElement)] pub struct MiddleEllipsis { text: SharedString, @@ -162,31 +165,8 @@ impl MiddleEllipsis { } impl RenderOnce for MiddleEllipsis { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let theme = cx.theme(); - let size = theme.text_size(TextSize::Base); - let line = size.to_pixels(window.rem_size()) * LEADING; - let text = self.text; - div() - .w_full() - .h(line) - .overflow_hidden() - .text_size(size) - .text_color(theme.colors.fg) - .child( - canvas( - move |bounds, window, _| { - let shown = fit_middle(&text, bounds.size.width, window); - shape(&shown, window) - }, - move |bounds, shaped, window, cx| { - shaped - .paint(bounds.origin, line, window, cx) - .expect("middle ellipsis failed to paint"); - }, - ) - .size_full(), - ) + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + cut_line(self.text, fit_middle) } } @@ -268,7 +248,19 @@ mod tests { mod fits { use gpui::{Pixels, TestAppContext}; - use super::{fit_end, width_of}; + use super::{fit_end, fit_middle, width_of}; + + #[gpui::test] + fn a_middle_cut_keeps_the_front_and_the_extension(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + cx.update(|window, _| { + let text = "A very long file name that keeps going.pdf"; + let room = width_of("A very long…going.pdf", window); + let cut = fit_middle(text, room, window); + assert!(cut.starts_with("A very") && cut.ends_with(".pdf"), "{cut}"); + assert!(width_of(&cut, window) <= room && cut.contains('…'), "{cut}"); + }); + } #[gpui::test] fn an_ellipsis_keeps_the_longest_front_that_fits(cx: &mut TestAppContext) {