diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index e99aa834..e3a1eb94 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -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, + /// 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, + /// Remote host, when the avatar only shows a dot for it. + host: Option, +} + impl Tty7App { pub(crate) fn tab_sidebar( &self, @@ -80,24 +139,28 @@ impl Tty7App { pos }; - let visible_by_section: Vec> = 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> = 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 = (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>> = Vec::new(); let visible = visible_by_section[group_ix].clone(); - let visible_tabs: Vec = visible.iter().map(|(i, _)| *i).collect(); + let visible_tabs: Vec = visible.clone(); let row_slots: Rc>>> = 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 { + 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> { let grouping = cx.global::().sidebar_grouping == SidebarGrouping::Repo; self.tabs diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 9ea299bc..eaf0d1c5 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -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 = 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 = 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);