Surface unread counts on sidebar launcher rows
The design's Slack/Linear/Gmail rows show numeric badges (12, 3) that real apps publish in their tab title prefix — "(12) Slack | …", "(3) Inbox — Linear", "(99+) Gmail". Add a pure parser ely_domain::parse_title_unread_count that recognises the leading "(N)" pattern and surfaces it through BrowserTab::unread_count(); the sidebar launcher row renders a glass pill badge whenever the count is non-zero, capped at "99+" for very high counts. No domain field, no fabrication: the badge appears only when a real website publishes its own unread count via the title. Tests cover canonical formats, non-prefixed titles, leading whitespace, and non-numeric / overflow inputs.
This commit is contained in:
@@ -220,6 +220,7 @@ impl ElyShell {
|
|||||||
let host = tab.url().host().map(|host| host.to_string());
|
let host = tab.url().host().map(|host| host.to_string());
|
||||||
let title = tab.title().to_string();
|
let title = tab.title().to_string();
|
||||||
let initial = title.chars().next().unwrap_or('?').to_string();
|
let initial = title.chars().next().unwrap_or('?').to_string();
|
||||||
|
let unread = tab.unread_count();
|
||||||
let group_name = SharedString::from(format!("launcher-{}", tab.id().as_str()));
|
let group_name = SharedString::from(format!("launcher-{}", tab.id().as_str()));
|
||||||
let close_id = SharedString::from(format!("launcher-close-{}", tab.id().as_str()));
|
let close_id = SharedString::from(format!("launcher-close-{}", tab.id().as_str()));
|
||||||
|
|
||||||
@@ -252,6 +253,7 @@ impl ElyShell {
|
|||||||
.text_color(rgb(text_color))
|
.text_color(rgb(text_color))
|
||||||
.child(title),
|
.child(title),
|
||||||
)
|
)
|
||||||
|
.when(unread > 0, |el| el.child(render_unread_badge(unread)))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.id(close_id)
|
.id(close_id)
|
||||||
@@ -354,6 +356,24 @@ fn profile_initial(name: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_unread_badge(count: u32) -> impl IntoElement {
|
||||||
|
let label = if count > 99 {
|
||||||
|
"99+".to_string()
|
||||||
|
} else {
|
||||||
|
count.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
div()
|
||||||
|
.px(px(6.0))
|
||||||
|
.py(px(1.0))
|
||||||
|
.rounded(px(999.0))
|
||||||
|
.bg(rgba(UNREAD_BADGE_BG))
|
||||||
|
.text_size(px(10.0))
|
||||||
|
.font_weight(gpui::FontWeight(500.0))
|
||||||
|
.text_color(rgb(colors::INK_3))
|
||||||
|
.child(label)
|
||||||
|
}
|
||||||
|
|
||||||
fn section_tabs_label(count: usize) -> impl IntoElement {
|
fn section_tabs_label(count: usize) -> impl IntoElement {
|
||||||
div()
|
div()
|
||||||
.pt(px(12.0))
|
.pt(px(12.0))
|
||||||
@@ -389,6 +409,7 @@ fn section_label(label: &'static str) -> impl IntoElement {
|
|||||||
|
|
||||||
pub(crate) const ACTIVE_NAV_BG: u32 = 0xffffffd9;
|
pub(crate) const ACTIVE_NAV_BG: u32 = 0xffffffd9;
|
||||||
pub(crate) const PANEL_BG: u32 = 0xffffffe0;
|
pub(crate) const PANEL_BG: u32 = 0xffffffe0;
|
||||||
|
const UNREAD_BADGE_BG: u32 = 0x281e140f;
|
||||||
|
|
||||||
pub(crate) fn panel_shadow() -> Vec<BoxShadow> {
|
pub(crate) fn panel_shadow() -> Vec<BoxShadow> {
|
||||||
vec![
|
vec![
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ pub use sync::{
|
|||||||
};
|
};
|
||||||
pub use tab::{
|
pub use tab::{
|
||||||
BrowserTab, DEFAULT_ZOOM_PERCENT, MAX_ZOOM_PERCENT, MIN_ZOOM_PERCENT, TabFlags, TabState,
|
BrowserTab, DEFAULT_ZOOM_PERCENT, MAX_ZOOM_PERCENT, MIN_ZOOM_PERCENT, TabFlags, TabState,
|
||||||
ZOOM_PERCENT_STEP, validate_zoom_percent,
|
ZOOM_PERCENT_STEP, parse_title_unread_count, validate_zoom_percent,
|
||||||
};
|
};
|
||||||
pub use tab_group::TabGroup;
|
pub use tab_group::TabGroup;
|
||||||
pub use update::UpdatePolicy;
|
pub use update::UpdatePolicy;
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ impl BrowserTab {
|
|||||||
&self.title
|
&self.title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn unread_count(&self) -> u32 {
|
||||||
|
parse_title_unread_count(&self.title)
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn url(&self) -> &UrlText {
|
pub fn url(&self) -> &UrlText {
|
||||||
&self.url
|
&self.url
|
||||||
@@ -274,3 +279,46 @@ pub fn validate_zoom_percent(value: u16) -> Result<u16, DomainError> {
|
|||||||
|
|
||||||
Err(DomainError::InvalidZoomPercent { value, min: MIN_ZOOM_PERCENT, max: MAX_ZOOM_PERCENT })
|
Err(DomainError::InvalidZoomPercent { value, min: MIN_ZOOM_PERCENT, max: MAX_ZOOM_PERCENT })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse_title_unread_count(title: &str) -> u32 {
|
||||||
|
let trimmed = title.trim_start();
|
||||||
|
let Some(rest) = trimmed.strip_prefix('(') else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let Some(close) = rest.find(')') else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
rest[..close].parse::<u32>().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::parse_title_unread_count;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_leading_parenthesised_count() {
|
||||||
|
assert_eq!(parse_title_unread_count("(12) Slack"), 12);
|
||||||
|
assert_eq!(parse_title_unread_count("(3) Inbox — Linear"), 3);
|
||||||
|
assert_eq!(parse_title_unread_count("(0) Quiet"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_prefixed_titles() {
|
||||||
|
assert_eq!(parse_title_unread_count("Slack"), 0);
|
||||||
|
assert_eq!(parse_title_unread_count("Inbox (12)"), 0);
|
||||||
|
assert_eq!(parse_title_unread_count(""), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_numeric_or_overflow_counts() {
|
||||||
|
assert_eq!(parse_title_unread_count("(99+) Gmail"), 0);
|
||||||
|
assert_eq!(parse_title_unread_count("(abc) Mail"), 0);
|
||||||
|
assert_eq!(parse_title_unread_count("(99999999999999999999) huge"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tolerates_leading_whitespace() {
|
||||||
|
assert_eq!(parse_title_unread_count(" (7) Slack"), 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user