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.
This commit is contained in:
1 parent
a846e10847
commit
501d1b0f1a
6 files changed
+87
-59
No files matched your search
@@ -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.
|
- 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`).
|
- 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 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -321,3 +321,4 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
| T26d | 2 | PASS | |
|
| T26d | 2 | PASS | |
|
||||||
| T26e | 1 | FAIL | an option list had no width limit, so a long device name ran it past the window |
|
| T26e | 1 | FAIL | an option list had no width limit, so a long device name ran it past the window |
|
||||||
| T26e | 2 | PASS | |
|
| 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 |
|
||||||
@@ -44,6 +44,11 @@ fn entries() -> Vec<DirEntry> {
|
|||||||
DirEntry::file("Walkthrough.mov", 184_000_000, ago(120)),
|
DirEntry::file("Walkthrough.mov", 184_000_000, ago(120)),
|
||||||
DirEntry::file("main.rs", 9_200, ago(1)),
|
DirEntry::file("main.rs", 9_200, ago(1)),
|
||||||
DirEntry::file("Archive.zip", 48_000_000, ago(700)),
|
DirEntry::file("Archive.zip", 48_000_000, ago(700)),
|
||||||
|
DirEntry::file(
|
||||||
|
"Quarterly review with the design team, final.pdf",
|
||||||
|
820_000,
|
||||||
|
ago(6),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
lists::DirEntry,
|
lists::DirEntry,
|
||||||
primitives::{FocusRing, tab_stop},
|
primitives::{FocusRing, tab_stop},
|
||||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||||
typography::LEADING,
|
typography::{LEADING, MiddleEllipsis},
|
||||||
};
|
};
|
||||||
|
|
||||||
type OnEntry = Rc<dyn Fn(&DirEntry, &mut Window, &mut App)>;
|
type OnEntry = Rc<dyn Fn(&DirEntry, &mut Window, &mut App)>;
|
||||||
@@ -157,13 +157,14 @@ impl RenderOnce for FileGrid {
|
|||||||
.child(FileIcon::entry(entry).size(IconSize::Xxl))
|
.child(FileIcon::entry(entry).size(IconSize::Xxl))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
|
.debug_selector(|| format!("file-name {ix}"))
|
||||||
.w_full()
|
.w_full()
|
||||||
.text_center()
|
.flex()
|
||||||
|
.justify_center()
|
||||||
.text_size(theme.text_size(TextSize::Sm))
|
.text_size(theme.text_size(TextSize::Sm))
|
||||||
.line_height(relative(LEADING))
|
.line_height(relative(LEADING))
|
||||||
.text_color(colors.fg)
|
.text_color(colors.fg)
|
||||||
.line_clamp(2)
|
.child(MiddleEllipsis::new(entry.name().clone())),
|
||||||
.child(entry.name().clone()),
|
|
||||||
)
|
)
|
||||||
.on_click(move |event, window, cx| {
|
.on_click(move |event, window, cx| {
|
||||||
pick(ix, window, cx);
|
pick(ix, window, cx);
|
||||||
|
|||||||
@@ -156,3 +156,31 @@ fn a_name_is_listed_once() {
|
|||||||
fn the_pick_is_one_of_the_entries() {
|
fn the_pick_is_one_of_the_entries() {
|
||||||
let _ = FileGrid::new("grid", [DirEntry::file("a", 1, then())]).selected("z");
|
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<Self>) -> 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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
+47
-55
@@ -1,7 +1,7 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, ShapedLine,
|
App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
|
||||||
SharedString, StatefulInteractiveElement, Styled, TextRun, Window, canvas, div, prelude::*,
|
ShapedLine, SharedString, StatefulInteractiveElement, Styled, TextRun, Window, canvas, div,
|
||||||
relative, transparent_black,
|
prelude::*, relative, transparent_black,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -108,7 +108,33 @@ fn fit_end(text: &str, width: Pixels, window: &mut Window) -> String {
|
|||||||
cut(low)
|
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)]
|
#[derive(IntoElement)]
|
||||||
pub struct Ellipsis {
|
pub struct Ellipsis {
|
||||||
text: SharedString,
|
text: SharedString,
|
||||||
@@ -122,34 +148,11 @@ impl Ellipsis {
|
|||||||
|
|
||||||
impl RenderOnce for Ellipsis {
|
impl RenderOnce for Ellipsis {
|
||||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||||
let text = self.text.clone();
|
cut_line(self.text, fit_end)
|
||||||
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(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)]
|
#[derive(IntoElement)]
|
||||||
pub struct MiddleEllipsis {
|
pub struct MiddleEllipsis {
|
||||||
text: SharedString,
|
text: SharedString,
|
||||||
@@ -162,31 +165,8 @@ impl MiddleEllipsis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for MiddleEllipsis {
|
impl RenderOnce for MiddleEllipsis {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||||
let theme = cx.theme();
|
cut_line(self.text, fit_middle)
|
||||||
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(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +248,19 @@ mod tests {
|
|||||||
mod fits {
|
mod fits {
|
||||||
use gpui::{Pixels, TestAppContext};
|
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]
|
#[gpui::test]
|
||||||
fn an_ellipsis_keeps_the_longest_front_that_fits(cx: &mut TestAppContext) {
|
fn an_ellipsis_keeps_the_longest_front_that_fits(cx: &mut TestAppContext) {
|
||||||
|
|||||||
Reference in new issue
Block a user