mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
feat(sidebar): tail-first label elision and a hover card for hidden details (#446)
* feat(sidebar): tail-first label elision and a hover card for hidden details ff * fix(sidebar): keep the head of a non-path label, and let the card expand what the row hid The elision landed with four gaps between what a row hides and what the hover card can give back. A renamed tab is elided like anything else, but `sidebar_info` still carried the old tooltip's guard and bailed out on any non-empty `tab.name`, so a long custom name was shortened by the row and the card refused to spell it out. An empty terminal title took the opposite path: the row falls back to `Shell 3`, the card compared that placeholder against the empty string it came from, found them different, and opened on a row that had hidden nothing. Both came from deriving the same strings twice; the row now hands `sidebar_info` what it rendered next to what it rendered it from, and the card is decided by comparison alone. `elide_path_keep_tail` was applied to every title, but a title is not always a path — `npm run dev`, or a name someone typed. Dropping the head of those says less than the truncation this replaced, so `elide_label` picks the rule: tail-first for a path, both edges otherwise. `elide_keep_edges` promised both ends and delivered neither on a token with no break in reach: the head ran to the 12-glyph cap, `head…` alone overran the budget, and it fell through to a bare tail. It now tries shorter heads before giving up, and never trades the whole tail away for a longer head (`feature/…` became `fea…thing`). Also: measure the active row at the MEDIUM weight it actually renders at; subtract the list's own padding from the text budget, so a label that "just fits" is not handed back to CSS truncation; rejoin a path with the separator it arrived with, instead of spelling one tab `C:\Users\dev\app` while it fits and `C:/…/app` once it does not; count the gap between the two diff counts rather than the space standing in for it; let the card wrap instead of truncating the one string it promised in full; and read the remote host off the same leaf the title came from. The elision tests shape through gpui's `NoopTextSystem`, where every glyph is one em — deterministic across the three CI targets, but blind to the proportional and CJK widths this exists for. Said so where the fixtures are built, rather than implying the pixels are real. --------- Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
This commit is contained in:
+396
-26
@@ -16,10 +16,13 @@ use crate::core::config::{Config, SidebarGrouping};
|
||||
use crate::terminal::git_status::GitStatusCache;
|
||||
use crate::ui::app::{TITLE_BAR_HEIGHT, Tty7App};
|
||||
use crate::ui::hints::tab_badge_label;
|
||||
use crate::ui::i18n::{L10nKey, t};
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||||
use crate::ui::reorder::{self, Reorder, Surface};
|
||||
use crate::ui::right_panel::RESIZE_HANDLE_WIDTH;
|
||||
use crate::ui::tab_strip::{DragTab, REORDER_SLIDE_MS};
|
||||
use crate::ui::tab_strip::{
|
||||
DragTab, REORDER_SLIDE_MS, abbreviate_home, elide_keep_edges, elide_label,
|
||||
elide_path_keep_tail, measure_text, strip_host_prefix,
|
||||
};
|
||||
|
||||
const MIN_SIDEBAR_WIDTH: f32 = 180.;
|
||||
|
||||
@@ -28,6 +31,47 @@ const MAX_SIDEBAR_WIDTH_RATIO: f32 = 0.5;
|
||||
|
||||
const ROW_GAP: f32 = 2.;
|
||||
|
||||
/// The row chrome the text budget has to be measured around. These are the
|
||||
/// numbers the layout below is built from, not a second guess at it — a row
|
||||
/// that elides against a budget wider than it really has falls back to CSS
|
||||
/// truncation, which drops the tail this whole module exists to keep.
|
||||
mod row_metrics {
|
||||
/// `border_r_1` on the sidebar itself.
|
||||
pub(super) const BORDER: f32 = 1.;
|
||||
/// `px_1` on the scrolling list that holds the rows.
|
||||
pub(super) const LIST_PAD: f32 = 4.;
|
||||
/// `pl_2` + `pr_2` on the row.
|
||||
pub(super) const ROW_PAD: f32 = 8.;
|
||||
/// The avatar handed to `tab_avatar`.
|
||||
pub(super) const AVATAR: f32 = 22.;
|
||||
/// `gap_2` between the row's children.
|
||||
pub(super) const GAP: f32 = 8.;
|
||||
/// The ⌘N badge, when one is shown.
|
||||
pub(super) const BADGE: f32 = 20.;
|
||||
/// `gap_1p5`, between the branch icon and its text and before the counts.
|
||||
pub(super) const META_GAP: f32 = 6.;
|
||||
/// The branch icon.
|
||||
pub(super) const BRANCH_ICON: f32 = 11.;
|
||||
|
||||
/// What a row can spend on text, before the badge is taken out.
|
||||
pub(super) const fn text_budget(width: f32) -> f32 {
|
||||
width - BORDER - 2. * LIST_PAD - 2. * ROW_PAD - AVATAR - GAP
|
||||
}
|
||||
}
|
||||
|
||||
/// What a sidebar row rendered, next to what it had to leave out, so the
|
||||
/// hover card can be built by comparison instead of deriving the same strings
|
||||
/// a second time — the two derivations have to agree, and the shortest way to
|
||||
/// guarantee that is to only ever have one.
|
||||
struct SidebarRowShown {
|
||||
/// The elided title, and the full string it came from. `None` when the
|
||||
/// row is showing a placeholder (`Shell 3`) rather than a real title,
|
||||
/// which nothing can expand.
|
||||
title: Option<(SharedString, SharedString)>,
|
||||
branch: Option<(SharedString, SharedString, u32, u32)>,
|
||||
cwd: Option<(SharedString, SharedString)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DragGroup;
|
||||
|
||||
@@ -37,6 +81,21 @@ impl Render for DragGroup {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every detail a sidebar row could not fit, collected so the hover card can
|
||||
/// be rendered from cloneable data (an `AnyElement` cannot be cloned, but the
|
||||
/// tooltip closure has to rebuild its content on every hover).
|
||||
#[derive(Clone)]
|
||||
struct SidebarInfo {
|
||||
/// Full path, when the row's title was elided.
|
||||
title: Option<SharedString>,
|
||||
/// Full branch plus diff counts, when the row's branch was elided.
|
||||
branch: Option<(SharedString, u32, u32)>,
|
||||
/// Full working directory, when the row's second line was elided.
|
||||
cwd: Option<SharedString>,
|
||||
/// Remote host, when the avatar only shows a dot for it.
|
||||
host: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
pub(crate) fn tab_sidebar(
|
||||
&self,
|
||||
@@ -80,24 +139,28 @@ impl Tty7App {
|
||||
pos
|
||||
};
|
||||
|
||||
let visible_by_section: Vec<Vec<(usize, String)>> = sections
|
||||
// The row shows an elided title and a branch; the filter used to read
|
||||
// only the elided title, so typing the branch you can see, or the part
|
||||
// of the path the row dropped, matched nothing. The label is built
|
||||
// here only when there is a query to match it against — the rows
|
||||
// themselves elide against measured width and no longer need it.
|
||||
let visible_by_section: Vec<Vec<usize>> = sections
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.tabs
|
||||
.iter()
|
||||
.map(|&i| (i, self.tab_label(&self.tabs[i], i, Some(window), cx)))
|
||||
// The row shows a truncated title and a branch; the filter
|
||||
// only ever read the truncated title, so typing the branch
|
||||
// you can see, or the part of the path the row elided,
|
||||
// matched nothing.
|
||||
.filter(|(i, label)| {
|
||||
.copied()
|
||||
.filter(|&i| {
|
||||
query.is_empty()
|
||||
|| label.to_lowercase().contains(&query)
|
||||
|| self.tabs[*i]
|
||||
|| self
|
||||
.tab_label(&self.tabs[i], i, Some(window), cx)
|
||||
.to_lowercase()
|
||||
.contains(&query)
|
||||
|| self.tabs[i]
|
||||
.leaf_title(Some(window), cx)
|
||||
.to_lowercase()
|
||||
.contains(&query)
|
||||
|| self.tabs[*i]
|
||||
|| self.tabs[i]
|
||||
.git_status(Some(window), cx)
|
||||
.is_some_and(|g| g.branch.to_lowercase().contains(&query))
|
||||
})
|
||||
@@ -106,6 +169,25 @@ impl Tty7App {
|
||||
.collect();
|
||||
|
||||
let pointer = window.mouse_position();
|
||||
// The row text is measured against real glyphs before it is elided:
|
||||
// `text_sm` is 0.875rem and `text_xs` 0.75rem, resolved here so the
|
||||
// measurement and the render use the same sizes and family.
|
||||
let font = gpui::Font {
|
||||
family: cx.theme().font_family.clone(),
|
||||
features: Default::default(),
|
||||
fallbacks: None,
|
||||
weight: Default::default(),
|
||||
style: Default::default(),
|
||||
};
|
||||
// The active row renders its title at `FontWeight::MEDIUM`, which is
|
||||
// wider than the regular weight in any proportional face. Measuring
|
||||
// it as regular would let the one row the user is looking at overflow
|
||||
// into the truncation this is here to avoid.
|
||||
let title_font_active = gpui::Font {
|
||||
weight: FontWeight::MEDIUM,
|
||||
..font.clone()
|
||||
};
|
||||
let rem = window.rem_size().as_f32();
|
||||
let rendered = |ix: &usize| !visible_by_section[*ix].is_empty();
|
||||
let repo_slots: Vec<usize> = (0..sections.len())
|
||||
.filter(|&ix| sections[ix].key.is_some())
|
||||
@@ -147,7 +229,7 @@ impl Tty7App {
|
||||
let group_key = section.key.clone();
|
||||
let mut rows: Vec<ContextMenu<Stateful<Div>>> = Vec::new();
|
||||
let visible = visible_by_section[group_ix].clone();
|
||||
let visible_tabs: Vec<usize> = visible.iter().map(|(i, _)| *i).collect();
|
||||
let visible_tabs: Vec<usize> = visible.clone();
|
||||
let row_slots: Rc<RefCell<Vec<Bounds<Pixels>>>> =
|
||||
Rc::new(RefCell::new(vec![Bounds::default(); visible.len()]));
|
||||
let row_preview = reorder::preview(
|
||||
@@ -156,7 +238,7 @@ impl Tty7App {
|
||||
visible.len(),
|
||||
pointer,
|
||||
);
|
||||
for (slot, (i, label)) in visible.into_iter().enumerate() {
|
||||
for (slot, i) in visible.into_iter().enumerate() {
|
||||
let badge_pos = badge_pos[i];
|
||||
let tab = &self.tabs[i];
|
||||
let is_active = i == active;
|
||||
@@ -172,6 +254,65 @@ impl Tty7App {
|
||||
Some((view.host_id(), cwd))
|
||||
}),
|
||||
);
|
||||
let badge_extra = if show_badges && badge_pos < 9 {
|
||||
row_metrics::BADGE + row_metrics::GAP
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
// Elision is measured against this budget so the label and
|
||||
// branch never wrap or overflow into CSS truncation.
|
||||
let label_avail = (row_metrics::text_budget(width) - badge_extra).max(48.);
|
||||
let title_size = 0.875 * rem;
|
||||
let meta_size = 0.75 * rem;
|
||||
let title_font = if is_active { &title_font_active } else { &font };
|
||||
// Title: elide the *full* label against the row budget, so a
|
||||
// wide sidebar shows the whole thing and a narrow one keeps
|
||||
// whichever end identifies it — the tail for a path, both
|
||||
// edges for anything else. A fixed segment cap
|
||||
// (`short_title`) would elide even when the row has room, so
|
||||
// only the width may decide here.
|
||||
//
|
||||
// `full_title` is the unelided string the card can expand
|
||||
// back to; `None` means the row is showing a placeholder that
|
||||
// no card can improve on.
|
||||
let (shown_title, full_title) =
|
||||
if let Some(name) = tab.name.as_ref().filter(|n| !n.trim().is_empty()) {
|
||||
// A renamed tab is elided like anything else — and so
|
||||
// the card has to be able to spell the name back out.
|
||||
let full = SharedString::from(name.trim().to_string());
|
||||
let shown = elide_label(
|
||||
&window.text_system(),
|
||||
title_font,
|
||||
title_size,
|
||||
&full,
|
||||
label_avail,
|
||||
);
|
||||
(shown, Some(full))
|
||||
} else {
|
||||
let raw_title = tab.leaf_title(Some(window), cx);
|
||||
let raw = abbreviate_home(strip_host_prefix(raw_title.trim()));
|
||||
if raw.trim().is_empty() {
|
||||
// Nothing to expand: the row is naming an unnamed
|
||||
// shell, not hiding a title behind an ellipsis.
|
||||
let placeholder = SharedString::from(t_fmt(
|
||||
L10nKey::TabUnnamedShell,
|
||||
&[("n", &((i + 1).to_string()))],
|
||||
));
|
||||
(placeholder, None)
|
||||
} else {
|
||||
let full = SharedString::from(raw.as_ref());
|
||||
let shown = elide_label(
|
||||
&window.text_system(),
|
||||
title_font,
|
||||
title_size,
|
||||
&full,
|
||||
label_avail,
|
||||
);
|
||||
(shown, Some(full))
|
||||
}
|
||||
};
|
||||
let mut branch_shown: Option<(SharedString, SharedString, u32, u32)> = None;
|
||||
let mut cwd_shown: Option<(SharedString, SharedString)> = None;
|
||||
let git_line = tab.git_status(Some(window), cx).map(|g| {
|
||||
let mut line = h_flex()
|
||||
.id(("sidebar-git", i))
|
||||
@@ -184,10 +325,58 @@ impl Tty7App {
|
||||
gpui::svg()
|
||||
.path("icons/git-branch.svg")
|
||||
.flex_shrink_0()
|
||||
.size(px(11.))
|
||||
.size(px(row_metrics::BRANCH_ICON))
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().truncate().child(g.branch.clone()));
|
||||
);
|
||||
// The diff counts are measured against real glyphs so the
|
||||
// branch can be elided to exactly the space they leave;
|
||||
// the counts themselves never wrap or shrink. They render
|
||||
// as two children of a `gap_1p5` row, so the gap between
|
||||
// them is measured rather than a space that stands in for
|
||||
// it.
|
||||
let mut counts_w = 0.;
|
||||
if g.added > 0 {
|
||||
counts_w += measure_text(
|
||||
&window.text_system(),
|
||||
&font,
|
||||
meta_size,
|
||||
&format!("+{}", g.added),
|
||||
);
|
||||
}
|
||||
if g.removed > 0 {
|
||||
counts_w += measure_text(
|
||||
&window.text_system(),
|
||||
&font,
|
||||
meta_size,
|
||||
&format!("−{}", g.removed),
|
||||
);
|
||||
}
|
||||
if g.added > 0 && g.removed > 0 {
|
||||
counts_w += row_metrics::META_GAP;
|
||||
}
|
||||
if counts_w > 0. {
|
||||
// The gap between the branch and the counts.
|
||||
counts_w += row_metrics::META_GAP;
|
||||
}
|
||||
// Branch: keep both ends (`window-…backdrop`) so its
|
||||
// identifying tail survives a narrow sidebar.
|
||||
let branch_avail =
|
||||
(label_avail - row_metrics::BRANCH_ICON - row_metrics::META_GAP - counts_w)
|
||||
.max(0.);
|
||||
let shown = elide_keep_edges(
|
||||
&window.text_system(),
|
||||
&font,
|
||||
meta_size,
|
||||
&g.branch,
|
||||
branch_avail,
|
||||
);
|
||||
branch_shown = Some((
|
||||
shown.clone(),
|
||||
SharedString::from(g.branch.clone()),
|
||||
g.added,
|
||||
g.removed,
|
||||
));
|
||||
line = line.child(div().flex_1().min_w_0().truncate().child(shown));
|
||||
if g.added > 0 || g.removed > 0 {
|
||||
let mut counts = h_flex()
|
||||
.id(("sidebar-diff", i))
|
||||
@@ -229,12 +418,54 @@ impl Tty7App {
|
||||
}
|
||||
line
|
||||
});
|
||||
// Outside a repo there is no branch line; the second line then
|
||||
// carries the compressed cwd with its root marker, so a tab
|
||||
// whose title is just a shell name still says where it lives.
|
||||
if git_line.is_none() {
|
||||
cwd_shown = tab
|
||||
.pane
|
||||
.focused_or_first(window, cx)
|
||||
.and_then(|leaf| {
|
||||
let view = leaf.read(cx);
|
||||
view.git_status_cwd()
|
||||
.map(|p| p.to_path_buf())
|
||||
.or_else(|| view.cwd())
|
||||
})
|
||||
.map(|cwd| {
|
||||
let full = SharedString::from(
|
||||
abbreviate_home(&cwd.display().to_string()).into_owned(),
|
||||
);
|
||||
let shown = elide_path_keep_tail(
|
||||
&window.text_system(),
|
||||
&font,
|
||||
meta_size,
|
||||
&full,
|
||||
label_avail,
|
||||
);
|
||||
(shown, full)
|
||||
})
|
||||
// The title already carries the whole path; a second
|
||||
// copy adds noise, not information.
|
||||
.filter(|(shown, _)| shown.as_ref() != shown_title.as_ref());
|
||||
}
|
||||
let rename_input = self
|
||||
.renaming
|
||||
.as_ref()
|
||||
.filter(|r| r.index == i)
|
||||
.map(|r| r.input.clone());
|
||||
|
||||
let shown = SidebarRowShown {
|
||||
title: full_title.map(|full| (shown_title.clone(), full)),
|
||||
branch: branch_shown.clone(),
|
||||
cwd: cwd_shown.clone(),
|
||||
};
|
||||
let info = self.sidebar_info(tab, window, cx, &shown);
|
||||
// Colors are captured by value so the tooltip builder (which
|
||||
// borrows no app state) can style the card on its own.
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let success = cx.theme().success;
|
||||
let danger = cx.theme().danger;
|
||||
|
||||
let label_region = match rename_input {
|
||||
Some(input) => div()
|
||||
.id(("sidebar-rename", i))
|
||||
@@ -248,24 +479,113 @@ impl Tty7App {
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap(px(2.))
|
||||
.when_some(
|
||||
self.tab_title_tooltip(tab, i, Some(window), cx),
|
||||
|col, title| {
|
||||
col.tooltip(move |window, cx| {
|
||||
gpui_component::tooltip::Tooltip::new(title.clone())
|
||||
.build(window, cx)
|
||||
.when_some(info, |col, info| {
|
||||
col.tooltip(move |window, cx| {
|
||||
// `Tooltip::element` rebuilds its content on
|
||||
// every hover, so the captured info is cloned
|
||||
// per call instead of being moved out.
|
||||
let info = info.clone();
|
||||
gpui_component::tooltip::Tooltip::element(move |_window, _cx| {
|
||||
let card = v_flex()
|
||||
.gap_1()
|
||||
// The card is the one place that
|
||||
// promised the whole string, so a long
|
||||
// path wraps here rather than being
|
||||
// truncated a second time.
|
||||
.when_some(info.title.clone(), |c, title| {
|
||||
c.child(
|
||||
div()
|
||||
.max_w(px(420.))
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(title),
|
||||
)
|
||||
})
|
||||
.when_some(
|
||||
info.branch.clone(),
|
||||
|c, (branch, added, removed)| {
|
||||
let mut line = h_flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child(
|
||||
gpui::svg()
|
||||
.path("icons/git-branch.svg")
|
||||
.flex_shrink_0()
|
||||
.size(px(11.))
|
||||
.text_color(muted),
|
||||
)
|
||||
.child(div().child(branch));
|
||||
if added > 0 {
|
||||
line = line.child(
|
||||
div()
|
||||
.text_color(success)
|
||||
.child(format!("+{added}")),
|
||||
);
|
||||
}
|
||||
if removed > 0 {
|
||||
line = line.child(
|
||||
div()
|
||||
.text_color(danger)
|
||||
.child(format!("−{removed}")),
|
||||
);
|
||||
}
|
||||
c.child(line)
|
||||
},
|
||||
)
|
||||
.when_some(info.cwd.clone(), |c, cwd| {
|
||||
c.child(
|
||||
div()
|
||||
.max_w(px(420.))
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child(cwd),
|
||||
)
|
||||
})
|
||||
.when_some(info.host.clone(), |c, host| {
|
||||
c.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child(
|
||||
gpui::svg()
|
||||
.path("icons/machine-remote.svg")
|
||||
.flex_shrink_0()
|
||||
.size(px(11.))
|
||||
.text_color(muted),
|
||||
)
|
||||
.child(div().truncate().child(host)),
|
||||
)
|
||||
});
|
||||
card
|
||||
})
|
||||
},
|
||||
)
|
||||
.build(window, cx)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.when(is_active, |d| d.font_weight(FontWeight::MEDIUM))
|
||||
.child(label),
|
||||
.child(shown_title),
|
||||
)
|
||||
.children(git_line)
|
||||
.when_some(cwd_shown, |col, (cwd, _)| {
|
||||
col.child(
|
||||
h_flex()
|
||||
.id(("sidebar-cwd", i))
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground.opacity(0.8))
|
||||
.child(div().flex_1().min_w_0().truncate().child(cwd)),
|
||||
)
|
||||
})
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
@@ -775,6 +1095,56 @@ impl Tty7App {
|
||||
.child(handle)
|
||||
}
|
||||
|
||||
/// What the sidebar row hid: the full title, the full branch and diff
|
||||
/// counts, the working directory, and the remote host the avatar only
|
||||
/// dots. `None` when the row showed everything — a card would add noise,
|
||||
/// not information. The host is included even for an untruncated row,
|
||||
/// because the title strips the `user@host:` prefix the avatar cannot
|
||||
/// spell out.
|
||||
///
|
||||
/// Every line is decided by comparing what the row rendered against the
|
||||
/// string it was elided from. Both come from the row itself: deriving
|
||||
/// them here a second time is how a renamed tab ended up with a name the
|
||||
/// row shortened and the card refused to expand.
|
||||
fn sidebar_info(
|
||||
&self,
|
||||
tab: &crate::ui::app::Tab,
|
||||
window: &mut Window,
|
||||
cx: &gpui::App,
|
||||
shown: &SidebarRowShown,
|
||||
) -> Option<SidebarInfo> {
|
||||
let elided = |pair: &Option<(SharedString, SharedString)>| {
|
||||
pair.as_ref()
|
||||
.filter(|(shown, full)| shown != full)
|
||||
.map(|(_, full)| full.clone())
|
||||
};
|
||||
let mut info = SidebarInfo {
|
||||
title: elided(&shown.title),
|
||||
branch: shown
|
||||
.branch
|
||||
.as_ref()
|
||||
.filter(|(shown, full, _, _)| shown != full)
|
||||
.map(|(_, full, added, removed)| (full.clone(), *added, *removed)),
|
||||
// The cwd only earns a card line when it was rendered *and*
|
||||
// elided: a repo row already shows the full path as its title, so
|
||||
// repeating the cwd under it would be noise, not information.
|
||||
cwd: elided(&shown.cwd),
|
||||
host: None,
|
||||
};
|
||||
// The host is read off the same leaf the title and cwd came from; a
|
||||
// split tab whose panes sit on different machines would otherwise
|
||||
// name whichever one happens to be first.
|
||||
if let Some(target) = tab.pane.focused_or_first(window, cx).and_then(|leaf| {
|
||||
leaf.read(cx)
|
||||
.remote_context()
|
||||
.map(|r| SharedString::from(r.target.clone()))
|
||||
}) {
|
||||
info.host = Some(target);
|
||||
}
|
||||
(info.title.is_some() || info.branch.is_some() || info.cwd.is_some() || info.host.is_some())
|
||||
.then_some(info)
|
||||
}
|
||||
|
||||
fn sidebar_group_keys(&self, cx: &gpui::App) -> Vec<Option<PathBuf>> {
|
||||
let grouping = cx.global::<Config>().sidebar_grouping == SidebarGrouping::Repo;
|
||||
self.tabs
|
||||
|
||||
+530
-9
@@ -46,6 +46,16 @@ fn shell_spec(shell: &DetectedShell) -> ShellSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strips a `user@host:` prefix a shell put in front of its title, leaving
|
||||
/// the path (or command) it actually names. A bare `host:` with no user is
|
||||
/// left alone — that is a drive letter on Windows.
|
||||
pub(crate) fn strip_host_prefix(raw: &str) -> &str {
|
||||
match raw.split_once(':') {
|
||||
Some((head, tail)) if head.contains('@') => tail,
|
||||
_ => raw,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn abbreviate_home(path: &str) -> std::borrow::Cow<'_, str> {
|
||||
use std::borrow::Cow;
|
||||
if path.starts_with('~') {
|
||||
@@ -68,15 +78,24 @@ pub(crate) fn abbreviate_home(path: &str) -> std::borrow::Cow<'_, str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The separator a path spells itself with. A path carrying a single `\` is
|
||||
/// a Windows path and has to be put back together with `\`: rejoining it with
|
||||
/// `/` would make one tab spell its location two ways, `C:\Users\dev\app`
|
||||
/// while it fits and `C:/…/app` once it has to be elided.
|
||||
fn path_separator(path: &str) -> char {
|
||||
if path.contains('\\') { '\\' } else { '/' }
|
||||
}
|
||||
|
||||
fn join_segments(segments: &[&str], sep: char) -> String {
|
||||
segments.join(sep.encode_utf8(&mut [0u8; 4]) as &str)
|
||||
}
|
||||
|
||||
pub(crate) fn short_title(raw: &str) -> String {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let after_host = match raw.split_once(':') {
|
||||
Some((head, tail)) if head.contains('@') => tail,
|
||||
_ => raw,
|
||||
};
|
||||
let after_host = strip_host_prefix(raw);
|
||||
let after_host = after_host.trim();
|
||||
if after_host.is_empty() {
|
||||
return String::new();
|
||||
@@ -99,7 +118,9 @@ pub(crate) fn short_title(raw: &str) -> String {
|
||||
(Kind::Relative, path)
|
||||
};
|
||||
|
||||
let segments: Vec<&str> = body.split('/').filter(|s| !s.is_empty()).collect();
|
||||
// Both separators: Windows shells report `C:\Users\…` while git and the
|
||||
// terminal integration use `/`, and a path must be cut on either one.
|
||||
let segments: Vec<&str> = body.split(['/', '\\']).filter(|s| !s.is_empty()).collect();
|
||||
if segments.is_empty() {
|
||||
return match kind {
|
||||
Kind::Home => "~",
|
||||
@@ -109,15 +130,16 @@ pub(crate) fn short_title(raw: &str) -> String {
|
||||
.to_string();
|
||||
}
|
||||
|
||||
let sep = path_separator(path);
|
||||
let depth = segments.len() + usize::from(matches!(kind, Kind::Home));
|
||||
let mut label = if depth > KEEP_SEGMENTS {
|
||||
let tail = &segments[segments.len() - KEEP_SEGMENTS..];
|
||||
format!("…/{}", tail.join("/"))
|
||||
format!("…{sep}{}", join_segments(tail, sep))
|
||||
} else {
|
||||
match kind {
|
||||
Kind::Home => format!("~/{}", segments.join("/")),
|
||||
Kind::Absolute => format!("/{}", segments.join("/")),
|
||||
Kind::Relative => segments.join("/"),
|
||||
Kind::Home => format!("~{sep}{}", join_segments(&segments, sep)),
|
||||
Kind::Absolute => format!("/{}", join_segments(&segments, sep)),
|
||||
Kind::Relative => join_segments(&segments, sep),
|
||||
}
|
||||
};
|
||||
if label.chars().count() > 40 {
|
||||
@@ -126,6 +148,267 @@ pub(crate) fn short_title(raw: &str) -> String {
|
||||
label
|
||||
}
|
||||
|
||||
/// Width of `text` shaped in `font` at `size`, in pixels.
|
||||
///
|
||||
/// The window's text system caches shaped runs, so measuring the same labels
|
||||
/// across frames is cheap. The sidebar elides against real glyph widths
|
||||
/// instead of guessing at character counts — that is the only way a mixed
|
||||
/// CJK/Latin label can be squeezed without tearing mid-token.
|
||||
pub(crate) fn measure_text(
|
||||
text_system: &gpui::WindowTextSystem,
|
||||
font: &gpui::Font,
|
||||
size: f32,
|
||||
text: &str,
|
||||
) -> f32 {
|
||||
text_system
|
||||
.shape_line(
|
||||
SharedString::from(text),
|
||||
px(size),
|
||||
&[gpui::TextRun {
|
||||
len: text.len(),
|
||||
font: font.clone(),
|
||||
color: gpui::Hsla::default(),
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}],
|
||||
None,
|
||||
)
|
||||
.width
|
||||
.as_f32()
|
||||
}
|
||||
|
||||
/// Elides a path from the front when it cannot fit `max_width`, keeping the
|
||||
/// root marker (drive letter, `~`, or the leading slash) and every trailing
|
||||
/// segment that fits.
|
||||
///
|
||||
/// The tail is what a user identifies a tab by — the file or directory they
|
||||
/// are actually working on — so it is never torn: whole segments drop off the
|
||||
/// front first (a half-eaten directory name reads as noise), and when even
|
||||
/// the last segment is too wide, only that segment is elided character by
|
||||
/// character, still tail-first.
|
||||
pub(crate) fn elide_path_keep_tail(
|
||||
text_system: &gpui::WindowTextSystem,
|
||||
font: &gpui::Font,
|
||||
size: f32,
|
||||
path: &str,
|
||||
max_width: f32,
|
||||
) -> SharedString {
|
||||
let path = path.trim();
|
||||
if path.is_empty() || measure_text(text_system, font, size, path) <= max_width {
|
||||
return SharedString::from(path);
|
||||
}
|
||||
let sep = path_separator(path);
|
||||
let segments: Vec<&str> = path.split(['/', '\\']).collect();
|
||||
// A leading slash splits into an empty first segment; `~` and drive
|
||||
// letters (`E:`) carry the same "where this tree lives" weight, and a
|
||||
// leading `…` means `short_title` already elided once — that marker is
|
||||
// replaced by the new elision instead of stacking two ellipses. Keep
|
||||
// whichever marker there is so the result never reads as a bare
|
||||
// relative path.
|
||||
let root: &str = match segments.first() {
|
||||
Some(&"") => "/",
|
||||
Some(&"~") => "~",
|
||||
Some(&"…") => "",
|
||||
Some(head) if head.ends_with(':') => head,
|
||||
_ => "",
|
||||
};
|
||||
let root_kept = segments
|
||||
.first()
|
||||
.is_some_and(|s| s.is_empty() || *s == "~" || *s == "…" || s.ends_with(':'));
|
||||
let prefix = if root.is_empty() {
|
||||
format!("…{sep}")
|
||||
} else if root == "/" {
|
||||
// The absolute-path root is already the slash itself.
|
||||
format!("/…{sep}")
|
||||
} else {
|
||||
format!("{root}{sep}…{sep}")
|
||||
};
|
||||
// Drop whole segments from the front until the remaining tail fits. The
|
||||
// width only shrinks as segments leave, so the first fit is the widest
|
||||
// one — greedy is optimal here.
|
||||
//
|
||||
// With a root marker, `head = 1` would spell the root, the ellipsis, and
|
||||
// then every remaining segment — strictly wider than the original that
|
||||
// already failed to fit — so that candidate is skipped rather than
|
||||
// measured.
|
||||
let mut head = if root_kept { 2 } else { 0 };
|
||||
while head < segments.len() {
|
||||
let candidate = if head == 0 {
|
||||
join_segments(&segments, sep)
|
||||
} else {
|
||||
format!("{prefix}{}", join_segments(&segments[head..], sep))
|
||||
};
|
||||
if measure_text(text_system, font, size, &candidate) <= max_width {
|
||||
return SharedString::from(candidate);
|
||||
}
|
||||
if head + 1 >= segments.len() {
|
||||
break;
|
||||
}
|
||||
head += 1;
|
||||
}
|
||||
// Even the last segment alone is too wide: keep its tail after the
|
||||
// ellipsis, with no slash so the reader sees the segment was torn.
|
||||
elide_tail_chars(
|
||||
text_system,
|
||||
font,
|
||||
size,
|
||||
segments[segments.len() - 1],
|
||||
max_width,
|
||||
)
|
||||
}
|
||||
|
||||
/// Characters a token is allowed to break on. Space is in the set because
|
||||
/// this also elides labels a human typed — `Backend server logs` — not just
|
||||
/// branch names, and a word boundary is the cut a reader forgives.
|
||||
const TOKEN_BREAKS: [char; 5] = ['-', '_', '/', '.', ' '];
|
||||
|
||||
/// The head this token would rather keep: six glyphs, extended to just past
|
||||
/// the next break so the cut lands on a boundary (`window-…` rather than
|
||||
/// `window…`). When no break is within reach the plain six is kept — running
|
||||
/// on to the cap would spend the whole budget on a prefix and leave the tail,
|
||||
/// which is what identifies the token, with nothing.
|
||||
fn preferred_head(chars: &[char]) -> usize {
|
||||
let base = chars.len().min(6);
|
||||
let cap = chars.len().min(12);
|
||||
if base >= cap {
|
||||
return base;
|
||||
}
|
||||
match chars[base..cap]
|
||||
.iter()
|
||||
.position(|c| TOKEN_BREAKS.contains(c))
|
||||
{
|
||||
Some(offset) => base + offset + 1,
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Elides the middle of a single token (a branch name, a shell name, a label
|
||||
/// the user typed) so both its head and its identifying tail survive:
|
||||
/// `window-transparency-backdrop` reads `window-…backdrop` in a narrow
|
||||
/// sidebar instead of losing its tail to a trailing ellipsis.
|
||||
///
|
||||
/// A head that fits but leaves no room for a tail is worse than no head at
|
||||
/// all, so the preferred head is given up for a shorter one when that is what
|
||||
/// it takes to keep a few trailing glyphs; only when even a three-glyph head
|
||||
/// cannot buy a tail does this fall back to a tail-only elision.
|
||||
pub(crate) fn elide_keep_edges(
|
||||
text_system: &gpui::WindowTextSystem,
|
||||
font: &gpui::Font,
|
||||
size: f32,
|
||||
text: &str,
|
||||
max_width: f32,
|
||||
) -> SharedString {
|
||||
let text = text.trim();
|
||||
if text.is_empty() || measure_text(text_system, font, size, text) <= max_width {
|
||||
return SharedString::from(text);
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let shaped = |head_n: usize, tail_n: usize| -> f32 {
|
||||
let mut s: String = chars[..head_n].iter().collect();
|
||||
s.push('…');
|
||||
s.extend(chars[chars.len() - tail_n..].iter());
|
||||
measure_text(text_system, font, size, &s)
|
||||
};
|
||||
// Width is monotone in the tail length, so a binary search finds the
|
||||
// longest tail that still fits behind a given head.
|
||||
let longest_tail = |head_n: usize| -> usize {
|
||||
let (mut lo, mut hi) = (0usize, chars.len() - head_n);
|
||||
while lo < hi {
|
||||
let mid = (lo + hi + 1) / 2;
|
||||
if shaped(head_n, mid) <= max_width {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
lo
|
||||
};
|
||||
/// Enough trailing glyphs to tell two sibling branches apart.
|
||||
const MIN_TAIL: usize = 3;
|
||||
let preferred = preferred_head(&chars);
|
||||
let mut candidates = vec![preferred, 6, 3];
|
||||
candidates.retain(|&h| h > 0 && h <= chars.len());
|
||||
candidates.dedup();
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for head_n in candidates {
|
||||
if shaped(head_n, 0) > max_width {
|
||||
continue;
|
||||
}
|
||||
let tail = longest_tail(head_n);
|
||||
if tail >= MIN_TAIL.min(chars.len() - head_n) {
|
||||
best = Some((head_n, tail));
|
||||
break;
|
||||
}
|
||||
if best.is_none_or(|(_, best_tail)| tail > best_tail) {
|
||||
best = Some((head_n, tail));
|
||||
}
|
||||
}
|
||||
let Some((head, tail)) = best.filter(|&(_, tail)| tail > 0) else {
|
||||
// No head buys a tail worth showing; a bare tail says more.
|
||||
return elide_tail_chars(text_system, font, size, text, max_width);
|
||||
};
|
||||
let mut out = String::with_capacity(head + 1 + tail);
|
||||
out.extend(chars[..head].iter());
|
||||
out.push('…');
|
||||
out.extend(chars[chars.len() - tail..].iter());
|
||||
SharedString::from(out)
|
||||
}
|
||||
|
||||
/// Elides a row label. A path keeps its tail — the file or directory being
|
||||
/// worked on — while anything else keeps both edges.
|
||||
///
|
||||
/// A shell title is not always a path: `npm run dev`, `man git-log`, or a name
|
||||
/// the user typed into the rename box. Running those through the path rule
|
||||
/// drops their head, which is the part that names them, and `… server logs`
|
||||
/// says less than the CSS truncation this replaced.
|
||||
pub(crate) fn elide_label(
|
||||
text_system: &gpui::WindowTextSystem,
|
||||
font: &gpui::Font,
|
||||
size: f32,
|
||||
text: &str,
|
||||
max_width: f32,
|
||||
) -> SharedString {
|
||||
if text.contains('/') || text.contains('\\') {
|
||||
elide_path_keep_tail(text_system, font, size, text, max_width)
|
||||
} else {
|
||||
elide_keep_edges(text_system, font, size, text, max_width)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the longest tail of `text` that fits after a bare ellipsis. Shared
|
||||
/// by the path and token elisions as their last resort.
|
||||
fn elide_tail_chars(
|
||||
text_system: &gpui::WindowTextSystem,
|
||||
font: &gpui::Font,
|
||||
size: f32,
|
||||
text: &str,
|
||||
max_width: f32,
|
||||
) -> SharedString {
|
||||
let budget = max_width - measure_text(text_system, font, size, "…");
|
||||
if budget <= 0. {
|
||||
return SharedString::from("…");
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let (mut lo, mut hi) = (0usize, chars.len());
|
||||
while lo < hi {
|
||||
let mid = (lo + hi + 1) / 2;
|
||||
let s: String = chars[chars.len() - mid..].iter().collect();
|
||||
if measure_text(text_system, font, size, &s) <= budget {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
if lo == 0 {
|
||||
return SharedString::from("…");
|
||||
}
|
||||
let mut out = String::with_capacity(1 + lo);
|
||||
out.push('…');
|
||||
out.extend(chars[chars.len() - lo..].iter());
|
||||
SharedString::from(out)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DragTab;
|
||||
|
||||
@@ -1298,6 +1581,7 @@ impl Tty7App {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
#[test]
|
||||
fn every_visible_agent_state_has_words_for_it() {
|
||||
@@ -1372,6 +1656,243 @@ mod tests {
|
||||
assert!(out.ends_with('…'));
|
||||
}
|
||||
|
||||
/// `TestAppContext` shapes through gpui's `NoopTextSystem`, where every
|
||||
/// glyph is exactly one em — weight-agnostic and, more to the point,
|
||||
/// CJK-agnostic. That keeps these tests identical on all three CI targets,
|
||||
/// but it also means they cannot speak to the proportional and mixed-script
|
||||
/// widths the elision exists for: what they pin is the contract — which
|
||||
/// parts of a label must survive, and that the result fits its budget.
|
||||
fn elide_setup(cx: &mut TestAppContext) -> (gpui::WindowTextSystem, gpui::Font, f32) {
|
||||
let size = 14.;
|
||||
(
|
||||
gpui::WindowTextSystem::new(cx.text_system().clone()),
|
||||
gpui::Font::default(),
|
||||
size,
|
||||
)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn elide_path_fits_shallow_paths_untouched(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let path = "~/tty7";
|
||||
let max = measure_text(&ts, &font, size, path) + 1.;
|
||||
assert_eq!(elide_path_keep_tail(&ts, &font, size, path, max), "~/tty7");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn elide_path_shows_the_whole_deep_path_when_the_budget_allows(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
// A wide sidebar must not elide a deep path: only the width may
|
||||
// decide, never a fixed segment cap.
|
||||
let path = "E:/work/toolbox/crates/tty7-core/src/client";
|
||||
let max = measure_text(&ts, &font, size, path) + 1.;
|
||||
assert_eq!(elide_path_keep_tail(&ts, &font, size, path, max), path);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn elide_path_keeps_drive_tail_and_budget(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let path = "E:/work/toolbox/src/ui/tab_sidebar.rs";
|
||||
let max = 200.;
|
||||
assert!(
|
||||
measure_text(&ts, &font, size, path) > max,
|
||||
"the fixture has to be wider than the budget to exercise elision"
|
||||
);
|
||||
let out = elide_path_keep_tail(&ts, &font, size, path, max);
|
||||
assert!(out.starts_with("E:/…/"), "drive letter survives: {out}");
|
||||
assert!(
|
||||
out.ends_with("tab_sidebar.rs"),
|
||||
"the file name always survives: {out}"
|
||||
);
|
||||
assert!(
|
||||
measure_text(&ts, &font, size, &out) <= max,
|
||||
"the elided label fits the budget"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn elide_path_keeps_tilde_and_leading_slash(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let home = "~/projects/toolbox/src/ui/tab_sidebar.rs";
|
||||
assert!(
|
||||
measure_text(&ts, &font, size, home) > 200.,
|
||||
"the fixture has to be wider than the budget to exercise elision"
|
||||
);
|
||||
let out = elide_path_keep_tail(&ts, &font, size, home, 200.);
|
||||
assert!(out.starts_with("~/…/"), "tilde root survives: {out}");
|
||||
assert!(out.ends_with("tab_sidebar.rs"));
|
||||
|
||||
let abs = "/usr/local/share/man/man1/git.1";
|
||||
assert!(
|
||||
measure_text(&ts, &font, size, abs) > 120.,
|
||||
"the fixture has to be wider than the budget to exercise elision"
|
||||
);
|
||||
let out = elide_path_keep_tail(&ts, &font, size, abs, 120.);
|
||||
assert!(out.starts_with("/…/"), "absolute root survives: {out}");
|
||||
assert!(out.ends_with("git.1"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn elide_path_tears_only_the_last_segment_as_a_last_resort(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let path = "E:/supercalifragilisticexpialidocious";
|
||||
let max = 60.;
|
||||
assert!(measure_text(&ts, &font, size, path) > max);
|
||||
let out = elide_path_keep_tail(&ts, &font, size, path, max);
|
||||
assert!(out.starts_with('…'), "a torn segment reads as torn: {out}");
|
||||
assert!(
|
||||
out.chars().nth(1) != Some('/'),
|
||||
"no slash after a torn segment: {out}"
|
||||
);
|
||||
assert!(out.ends_with('s'), "the word's tail survives: {out}");
|
||||
assert!(measure_text(&ts, &font, size, &out) <= max);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn elide_edges_keeps_both_ends_of_a_branch(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let branch = "window-transparency-backdrop";
|
||||
let max = 140.;
|
||||
assert!(measure_text(&ts, &font, size, branch) > max);
|
||||
let out = elide_keep_edges(&ts, &font, size, branch, max);
|
||||
assert!(out.starts_with("window-"), "head survives: {out}");
|
||||
assert!(out.ends_with("backdrop"), "tail survives: {out}");
|
||||
assert!(out.contains('…'));
|
||||
assert!(measure_text(&ts, &font, size, &out) <= max);
|
||||
assert!(out.chars().count() < branch.chars().count());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn elide_edges_leaves_short_branches_alone(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let branch = "main";
|
||||
let max = measure_text(&ts, &font, size, branch) + 1.;
|
||||
assert_eq!(elide_keep_edges(&ts, &font, size, branch, max), "main");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn elide_edges_falls_back_to_a_tail_sliver_when_the_head_cannot_fit(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let branch = "window-transparency-backdrop";
|
||||
let out = elide_keep_edges(&ts, &font, size, branch, 30.);
|
||||
assert!(out.starts_with('…'));
|
||||
assert!(measure_text(&ts, &font, size, &out) <= 30.);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn elide_path_cuts_windows_backslash_paths_on_segments(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let path = r"C:\Users\dev\AppData\Local\Temp\verify-build";
|
||||
let max = 200.;
|
||||
assert!(measure_text(&ts, &font, size, path) > max);
|
||||
let out = elide_path_keep_tail(&ts, &font, size, path, max);
|
||||
assert!(out.starts_with(r"C:\…\"), "drive letter survives: {out}");
|
||||
assert!(
|
||||
out.ends_with("verify-build"),
|
||||
"the leaf segment survives: {out}"
|
||||
);
|
||||
assert!(measure_text(&ts, &font, size, &out) <= max);
|
||||
}
|
||||
|
||||
/// One tab must not spell its location two ways depending on how wide the
|
||||
/// sidebar happens to be.
|
||||
#[gpui::test]
|
||||
fn elide_path_keeps_the_separator_the_path_arrived_with(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let windows = r"C:\Users\dev\projects\toolbox\src\ui\app.rs";
|
||||
let wide = measure_text(&ts, &font, size, windows) + 1.;
|
||||
assert_eq!(
|
||||
elide_path_keep_tail(&ts, &font, size, windows, wide),
|
||||
windows,
|
||||
"a path that fits is left exactly as it arrived"
|
||||
);
|
||||
let out = elide_path_keep_tail(&ts, &font, size, windows, 120.);
|
||||
assert!(!out.contains('/'), "no forward slash creeps in: {out}");
|
||||
|
||||
let unix = "/home/dev/projects/toolbox/src/ui/app.rs";
|
||||
let out = elide_path_keep_tail(&ts, &font, size, unix, 120.);
|
||||
assert!(!out.contains('\\'), "no backslash creeps in: {out}");
|
||||
}
|
||||
|
||||
/// A branch with no `-`, `_`, `/` or `.` in reach used to lose its head
|
||||
/// entirely, which is the one thing this function promises not to do.
|
||||
#[gpui::test]
|
||||
fn elide_edges_keeps_a_head_on_a_separatorless_token(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let branch = "verylongbranchnamewithoutseps";
|
||||
for max in [60., 80., 100., 120.] {
|
||||
let out = elide_keep_edges(&ts, &font, size, branch, max);
|
||||
assert!(
|
||||
out.starts_with('v'),
|
||||
"head survives at {max}px: {out}",
|
||||
max = max
|
||||
);
|
||||
assert!(out.ends_with('s'), "tail survives at {max}px: {out}");
|
||||
assert!(measure_text(&ts, &font, size, &out) <= max);
|
||||
}
|
||||
}
|
||||
|
||||
/// A head that fits but leaves nothing behind the ellipsis says less than
|
||||
/// a shorter head that keeps the identifying tail.
|
||||
#[gpui::test]
|
||||
fn elide_edges_gives_up_head_room_to_keep_a_tail(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let branch = "feature/some-really-long-thing";
|
||||
let max = 80.;
|
||||
assert!(measure_text(&ts, &font, size, branch) > max);
|
||||
let out = elide_keep_edges(&ts, &font, size, branch, max);
|
||||
assert!(
|
||||
!out.ends_with('…'),
|
||||
"the tail is never traded away for a longer head: {out}"
|
||||
);
|
||||
assert!(out.ends_with('g'), "the identifying tail survives: {out}");
|
||||
assert!(measure_text(&ts, &font, size, &out) <= max);
|
||||
}
|
||||
|
||||
/// The sidebar title is not always a path. `elide_label` has to notice,
|
||||
/// because the path rule drops the head — and for a command line or a name
|
||||
/// the user typed, the head is the part that names it.
|
||||
#[gpui::test]
|
||||
fn elide_label_keeps_the_head_of_a_non_path(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
for text in ["Backend server logs", "npm run dev -- --watch"] {
|
||||
let max = 90.;
|
||||
assert!(measure_text(&ts, &font, size, text) > max);
|
||||
let out = elide_label(&ts, &font, size, text, max);
|
||||
let first = text.chars().next().unwrap();
|
||||
assert!(
|
||||
out.starts_with(first),
|
||||
"a non-path keeps its head: {out} (from {text})"
|
||||
);
|
||||
assert!(measure_text(&ts, &font, size, &out) <= max);
|
||||
}
|
||||
}
|
||||
|
||||
/// …while a path still gets the tail-first treatment through the same
|
||||
/// entry point.
|
||||
#[gpui::test]
|
||||
fn elide_label_still_keeps_the_tail_of_a_path(cx: &mut TestAppContext) {
|
||||
let (ts, font, size) = elide_setup(cx);
|
||||
let path = "~/projects/toolbox/src/ui/tab_sidebar.rs";
|
||||
let out = elide_label(&ts, &font, size, path, 200.);
|
||||
assert!(out.starts_with("~/…/"), "root survives: {out}");
|
||||
assert!(out.ends_with("tab_sidebar.rs"), "leaf survives: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_title_cuts_windows_paths_on_backslashes() {
|
||||
assert_eq!(
|
||||
short_title(r"C:\Users\dev\projects\app"),
|
||||
r"…\dev\projects\app"
|
||||
);
|
||||
assert_eq!(
|
||||
short_title(r"C:\Users\dev\repo\deep\path\src\ui"),
|
||||
r"…\path\src\ui"
|
||||
);
|
||||
// A shallow Windows path keeps its drive and its backslashes.
|
||||
assert_eq!(short_title(r"C:\Users\app"), r"C:\Users\app");
|
||||
}
|
||||
|
||||
/// One chip is 100 wide plus a 6 gap, so this is "room for exactly four".
|
||||
const FOUR_CHIPS: f32 = 4. * (CHIP_MIN_W + CHIP_GAP);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user