//! The pieces every settings page is built from, after T3 Code's settings //! layout: titled sections over raised cards of rows, each row a title and //! description on the left and its control on the right (stacked below //! 512px), with a reset button beside the title while the value differs //! from its default. Plus the controls: switches, dropdowns, steppers, text //! fields and the model pickers. use std::{cell::Cell, f32::consts::PI, rc::Rc, time::Duration}; use gpui_kit::component::{ Icon, IconName, Sizable as _, h_flex, input::{Input, InputState}, menu::{DropdownMenu as _, PopupMenuItem}, tooltip::Tooltip, v_flex, }; use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; use super::SettingsView; use crate::claude::Effort; use crate::models::{Catalog, ProbeStatus, Provider, split_key}; use crate::session::Reasoning; use crate::settings::{self, Settings}; use crate::ui::icons::Lucide; use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette}; use crate::ui::pickers; use crate::ui::workspace::{SHEET_INSET, SIDEBAR_WIDTH}; /// Puts a setting back to its default. pub(crate) type Reset = Rc; /// The widest a settings page grows (T3 Code's "readable" width). pub(crate) const READABLE_WIDTH: f32 = 896.; /// Providers and Connections. pub(crate) const WIDE_WIDTH: f32 = 1024.; /// Diagnostics. pub(crate) const EXPANDED_WIDTH: f32 = 1152.; /// Rows narrower than this stack their control under the text. const STACK_BELOW: f32 = 512.; /// The page's padding on either side. pub(crate) const PAGE_PADDING: f32 = 24.; /// The row or section a settings search jumped to: it scrolls into view and /// pulses twice. pub(crate) struct Target { pub id: &'static str, /// Tells one jump's pulse from the next one's. pub generation: usize, /// Where the target was painted, for scrolling to it. pub bounds: Rc>>>, } impl Global for Target {} /// Whether a row on a page of `max_width` stacks its control under its /// text in this window. fn stacked(window: &Window, max_width: f32) -> bool { content_width(window, max_width) < STACK_BELOW } /// How wide a page of `max_width` is in this window. pub(crate) fn content_width(window: &Window, max_width: f32) -> f32 { let available = f32::from(window.viewport_size().width) - SIDEBAR_WIDTH - SHEET_INSET - PAGE_PADDING * 2.; available.min(max_width) } /// Wraps `element` so a search jump to `id` can find and pulse it. fn targetable(element: Div, id: Option<&'static str>, radius: f32, cx: &App) -> Div { let Some(target) = id.and_then(|id| cx.try_global::().filter(|target| target.id == id)) else { return element; }; let p = metal::palette(cx); let bounds = target.bounds.clone(); element .relative() .child( canvas(move |area, _, _| bounds.set(Some(area)), |_, _, _, _| {}) .absolute() .inset_0(), ) .child( div() .absolute() .inset_0() .rounded(px(radius)) .border_2() .border_color(p.green_deep.opacity(0.7)) .with_animation( ElementId::NamedInteger("settings-target".into(), target.generation as u64), Animation::new(Duration::from_millis(1300)).with_easing(|t| (2. * PI * t).sin().powi(2)), |ring, delta| ring.opacity(delta), ), ) } /// One setting: its title, what it does and its status on the left, the /// control on the right. #[derive(IntoElement)] pub(crate) struct SettingRow { id: Option<&'static str>, title: SharedString, icon: Option, title_suffix: Option, description: Option, status: Option, control: Option, reset: Option, below: Option, dimmed: bool, max_width: f32, } impl SettingRow { pub fn new(title: impl Into) -> Self { Self { id: None, title: title.into(), icon: None, title_suffix: None, description: None, status: None, control: None, reset: None, below: None, dimmed: false, max_width: READABLE_WIDTH, } } /// The anchor a settings search jumps to. pub fn id(mut self, id: &'static str) -> Self { self.id = Some(id); self } /// An icon before the title. pub fn icon(mut self, icon: impl IntoElement) -> Self { self.icon = Some(icon.into_any_element()); self } /// Something after the title, such as a version. pub fn title_suffix(mut self, suffix: impl IntoElement) -> Self { self.title_suffix = Some(suffix.into_any_element()); self } pub fn description(mut self, description: impl Into) -> Self { self.description = Some(description.into()).filter(|text: &SharedString| !text.is_empty()); self } /// A line under the description saying how things stand. pub fn status(mut self, status: impl Into) -> Self { let status: SharedString = status.into(); if !status.is_empty() { self.status = Some(status.into_any_element()); } self } pub fn status_element(mut self, status: impl IntoElement) -> Self { self.status = Some(status.into_any_element()); self } pub fn control(mut self, control: impl IntoElement) -> Self { self.control = Some(control.into_any_element()); self } /// Shows the reset button, which runs `reset`. pub fn reset(mut self, reset: Option) -> Self { self.reset = reset; self } /// Content under the row, like the fields a switch reveals. pub fn below(mut self, below: impl IntoElement) -> Self { self.below = Some(below.into_any_element()); self } /// Fades the row, for settings that don't apply right now. pub fn dimmed(mut self, dimmed: bool) -> Self { self.dimmed = dimmed; self } /// The width of the page the row is on, for deciding when it stacks. pub fn page_width(mut self, width: f32) -> Self { self.max_width = width; self } } impl RenderOnce for SettingRow { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let p = metal::palette(cx); let stacked = stacked(window, self.max_width); let title = self.title.clone(); let text = v_flex() .flex_1() .min_w_0() .gap(px(4.)) .when(self.dimmed, |this| this.opacity(0.64)) .child( h_flex() .min_h(px(20.)) .gap(px(6.)) .children(self.icon) .when(!self.title.is_empty(), |this| { this.child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(self.title)) }) .children(self.title_suffix) .when_some(self.reset, |this, reset| this.child(reset_button(&title, reset, p))), ) .when_some(self.description, |this, description| { this.child( div() .max_w(px(576.)) .text_size(px(12.)) .line_height(px(18.)) .text_color(p.ink3) .child(description), ) }) .when_some(self.status, |this, status| { this.child(div().pt(px(2.)).text_size(px(12.)).line_height(px(17.)).text_color(p.ink3).child(status)) }); let control = self.control.map(|control| { h_flex() .gap(px(8.)) .map(|this| if stacked { this.w_full() } else { this.flex_none().justify_end() }) .child(control) }); let row = div() .px(px(16.)) .pt(px(12.)) .pb(px(if self.below.is_some() { 4. } else { 12. })) .child( div() .flex() .map(|this| { if stacked { this.flex_col().gap(px(12.)) } else { this.flex_row().items_center().gap(px(32.)) } }) .child(text) .children(control), ) .when_some(self.below, |this, below| this.child(div().pt(px(10.)).pb(px(8.)).child(below))); targetable(row, self.id, 12., cx) } } /// T3 Code's per-row reset: a small ghost button with an undo arrow. fn reset_button(title: &SharedString, reset: Reset, p: &Palette) -> impl IntoElement { div() .id(SharedString::from(format!("reset-{title}"))) .flex() .flex_none() .items_center() .justify_center() .size(px(20.)) .rounded_full() .cursor_pointer() .text_color(p.ink3) .hover(|style| style.bg(p.row_hover).text_color(p.ink)) .tooltip(|window, cx| Tooltip::new("Reset to default").build(window, cx)) .on_click(move |_, window, cx| reset(window, cx)) .child(Icon::new(Lucide::Undo2).size(px(12.))) } /// A reset for a setting read with `get`: shown while it differs from the /// default, and putting the default back with `set`. pub(crate) fn reset( view: &WeakEntity, get: fn(&Settings) -> T, set: fn(&mut Settings, T), ) -> Option { let differs = settings::get(|settings| get(settings) != get(&Settings::default())); let view = view.clone(); differs.then(|| -> Reset { Rc::new(move |window, cx| edit(&view, window, cx, |settings| set(settings, get(&Settings::default())))) }) } /// A titled group of settings on a page. pub(crate) struct Section { id: Option<&'static str>, title: SharedString, icon: Option, description: Option, title_action: Option, header_action: Option, rows: Vec, plain: bool, hide_title: bool, } impl FluentBuilder for Section {} impl Section { pub fn new(title: impl Into) -> Self { Self { id: None, title: title.into(), icon: None, description: None, title_action: None, header_action: None, rows: Vec::new(), plain: false, hide_title: false, } } pub fn id(mut self, id: &'static str) -> Self { self.id = Some(id); self } pub fn icon(mut self, icon: impl IntoElement) -> Self { self.icon = Some(icon.into_any_element()); self } pub fn description(mut self, description: impl Into) -> Self { self.description = Some(description.into()); self } /// A control right after the title, such as an add button. pub fn title_action(mut self, action: impl IntoElement) -> Self { self.title_action = Some(action.into_any_element()); self } /// A control at the far end of the header. pub fn header_action(mut self, action: impl IntoElement) -> Self { self.header_action = Some(action.into_any_element()); self } pub fn row(mut self, row: impl IntoElement) -> Self { self.rows.push(row.into_any_element()); self } pub fn rows(mut self, rows: impl IntoIterator) -> Self { self.rows.extend(rows); self } /// No card: the rows sit on the sheet, a little apart. pub fn plain(mut self) -> Self { self.plain = true; self } /// Leaves the title off, like T3 Code's `hideTitle`. pub fn hide_title(mut self) -> Self { self.hide_title = true; self } pub fn render(self, cx: &App) -> AnyElement { let p = metal::palette(cx); if self.hide_title { let rows = v_flex().gap(px(4.)).children(self.rows); return targetable(rows, self.id, 18., cx).into_any_element(); } let header = h_flex() .min_h(px(28.)) .px(px(16.)) .gap(px(8.)) .child( h_flex() .flex_1() .min_w_0() .gap(px(8.)) .children(self.icon) .child(section_title(self.title, p)) .children(self.title_action), ) .children(self.header_action); let header = targetable(header, self.id, 10., cx); v_flex() .gap(px(10.)) .child( v_flex().gap(px(3.)).child(header).when_some(self.description, |this, description| { this.child(div().px(px(16.)).text_size(px(12.)).line_height(px(18.)).text_color(p.ink3).child(description)) }), ) .map(|this| { if self.plain { this.child(v_flex().gap(px(4.)).children(self.rows)) } else { this.child(card(self.rows, p)) } }) .into_any_element() } } pub(crate) fn section_title(title: impl Into, p: &Palette) -> Div { div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink2).child(title.into()) } /// A raised card of rows, with hairlines between them. pub(crate) fn card(rows: Vec, p: &Palette) -> Div { let count = rows.len(); v_flex().rounded(px(18.)).raised(p).children(rows.into_iter().enumerate().flat_map(|(ix, row)| { let rule = (ix + 1 < count).then(|| div().px(px(16.)).child(metal::rule(p)).into_any_element()); std::iter::once(row).chain(rule) })) } /// A titled card of rows: the short form of [`Section`]. pub(crate) fn section(title: &'static str, description: Option<&'static str>, rows: Vec, p: &Palette) -> AnyElement { let _ = p; v_flex() .gap(px(10.)) .child( v_flex() .gap(px(3.)) .child(h_flex().min_h(px(28.)).px(px(16.)).child(section_title(title, p))) .when_some(description, |this, description| { this.child(div().px(px(16.)).text_size(px(12.)).line_height(px(18.)).text_color(p.ink3).child(description)) }), ) .child(card(rows, p)) .into_any_element() } /// A section that starts closed, opened by its title: T3 Code's folded /// section, as used for "Legacy features". pub(crate) fn folded( id: &'static str, title: &'static str, open: bool, rows: Vec, view: &WeakEntity, p: &Palette, ) -> AnyElement { let view = view.clone(); v_flex() .gap(px(10.)) .child( h_flex() .id(SharedString::from(format!("fold-{id}"))) .min_h(px(28.)) .px(px(16.)) .gap(px(6.)) .cursor_pointer() .text_color(p.ink2) .hover(|style| style.text_color(p.ink)) .on_click(move |_, _, cx| { let _ = view.update(cx, |view, cx| { view.toggle_fold(id); cx.notify(); }); }) .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(title)) .child( Icon::new(IconName::ChevronRight) .size(px(16.)) .text_color(p.ink3) .when(open, |icon| icon.rotate(Radians(PI / 2.))), ), ) .when(open, |this| this.child(card(rows, p))) .into_any_element() } /// A muted line inside a card, for empty lists. pub(crate) fn empty_row(text: impl Into, p: &Palette) -> AnyElement { div() .px(px(16.)) .py(px(18.)) .text_size(px(13.)) .text_color(p.ink3) .child(text.into()) .into_any_element() } /// A short note under a section, like T3 Code's footnotes. pub(crate) fn note(text: impl Into, p: &Palette) -> Div { div().px(px(16.)).text_size(px(12.)).line_height(px(18.)).text_color(p.ink3).child(text.into()) } // ---- controls ------------------------------------------------------------------ /// Saves an edit, then repaints the view and tells the conversations. pub(crate) fn edit(view: &WeakEntity, window: &mut Window, cx: &mut App, change: impl FnOnce(&mut Settings)) { settings::update(change); let _ = view.update(cx, |view, cx| view.changed(window, cx)); } pub(crate) fn switch( id: &'static str, on: bool, view: &WeakEntity, p: &Palette, set: impl Fn(&mut Settings, bool) + 'static, ) -> Stateful
{ switch_then(id, on, view, p, set, |_| {}) } /// A switch that runs `then` after saving. pub(crate) fn switch_then( id: &'static str, on: bool, view: &WeakEntity, p: &Palette, set: impl Fn(&mut Settings, bool) + 'static, then: impl Fn(&mut App) + 'static, ) -> Stateful
{ let view = view.clone(); metal::toggle(id, on, p, move |on, window, cx| { edit(&view, window, cx, |settings| set(settings, on)); then(cx); }) } /// A dropdown of fixed choices, at least `width` wide. pub(crate) fn choice( id: &'static str, current: T, options: &'static [(T, &'static str)], view: &WeakEntity, set: impl Fn(&mut Settings, T) + 'static, ) -> AnyElement { let options: Vec<(T, SharedString)> = options.iter().map(|(value, label)| (*value, SharedString::from(*label))).collect(); choice_of(id, current, options, None, view, set) } /// [`choice`] with labels made at runtime, and an optional label for the /// button that differs from the selected item's. pub(crate) fn choice_of( id: &'static str, current: T, options: Vec<(T, SharedString)>, button_label: Option, view: &WeakEntity, set: impl Fn(&mut Settings, T) + 'static, ) -> AnyElement { let label = button_label .or_else(|| options.iter().find(|(value, _)| *value == current).map(|(_, label)| label.clone())) .unwrap_or_default(); let view = view.clone(); let set = Rc::new(set); MetalButton::new(id) .compact() .label(label) .caret() .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| { menu = menu.min_w(px(200.)); for (value, label) in &options { let value = *value; let view = view.clone(); let set = set.clone(); menu = menu.item(PopupMenuItem::new(label.clone()).checked(value == current).on_click(move |_, window, cx| { let set = set.clone(); edit(&view, window, cx, move |settings| set(settings, value)); })); } menu }) .into_any_element() } /// One option of a [`rich_choice`]: an icon, a label and a line about it. pub(crate) struct RichOption { pub value: T, pub icon: Option, pub label: &'static str, pub description: &'static str, } /// A dropdown whose items explain themselves, like T3 Code's Permissions /// select. pub(crate) fn rich_choice( id: &'static str, current: T, options: Vec>, view: &WeakEntity, set: impl Fn(&mut Settings, T) + 'static, ) -> AnyElement { let selected = options.iter().find(|option| option.value == current); let label = selected.map_or("", |option| option.label); let icon = selected.and_then(|option| option.icon.clone()); let view = view.clone(); let set = Rc::new(set); let options = Rc::new(options); MetalButton::new(id) .compact() .when_some(icon, |this, icon| this.icon(icon)) .label(label) .caret() .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, cx| { let p = metal::palette(cx); menu = menu.min_w(px(300.)); for option in options.iter() { let value = option.value; let view = view.clone(); let set = set.clone(); let (label, description) = (option.label, option.description); let (ink, ink3) = (p.ink, p.ink3); menu = menu.item( PopupMenuItem::element(move |_, _| { v_flex() .py(px(2.)) .gap(px(1.)) .child(div().text_size(px(13.)).font_weight(FontWeight::SEMIBOLD).text_color(ink).child(label)) .child(div().text_size(px(12.)).text_color(ink3).child(description)) }) .when_some(option.icon.clone(), |item, icon| item.icon(icon)) .checked(value == current) .on_click(move |_, window, cx| { let set = set.clone(); edit(&view, window, cx, move |settings| set(settings, value)); }), ); } menu }) .into_any_element() } /// A number with − and + caps, like T3 Code's interval field. #[allow(clippy::too_many_arguments)] pub(crate) fn stepper( id: &'static str, value: u64, step: u64, (min, max): (u64, u64), unit: &'static str, view: &WeakEntity, p: &Palette, set: impl Fn(&mut Settings, u64) + 'static, ) -> Div { let set = Rc::new(set); let button = |suffix: &'static str, icon: IconName, next: u64, enabled: bool| { let view = view.clone(); let set = set.clone(); MetalButton::new(SharedString::from(format!("{id}-{suffix}"))) .ghost() .mini() .icon(icon) .disabled(!enabled) .on_click(move |_, window, cx| { let set = set.clone(); edit(&view, window, cx, move |settings| set(settings, next)); }) }; h_flex() .gap(px(8.)) .child( h_flex() .h(px(30.)) .px(px(3.)) .gap(px(2.)) .rounded(px(12.)) .well(p) .child(button("less", IconName::Minus, value.saturating_sub(step).max(min), value > min)) .child( div() .min_w(px(48.)) .flex() .justify_center() .font_family(metal::MONO) .text_size(px(13.)) .text_color(p.ink) .child(value.to_string()), ) .child(button("more", IconName::Plus, (value + step).min(max), value < max)), ) .when(!unit.is_empty(), |this| this.child(div().text_size(px(12.5)).text_color(p.ink3).child(unit))) } /// Focuses a field once it has been drawn, for fields a click reveals. pub(crate) fn focus_later(input: &Entity, window: &mut Window) { let input = input.clone(); window.on_next_frame(move |window, cx| input.update(cx, |input, cx| input.focus(window, cx))); window.refresh(); } /// A search field: a well with plain text, unlike the mono value fields. pub(crate) fn search_field(input: &Entity, width: f32, cx: &App) -> Div { let p = metal::palette(cx); h_flex() .w(px(width)) .h(px(30.)) .px(px(11.)) .gap(px(8.)) .rounded(px(12.)) .well(p) .text_size(px(13.)) .text_color(p.ink) .child(Icon::new(IconName::Search).size(px(14.)).text_color(p.ink3)) .child(div().flex_1().min_w_0().child(Input::new(input).appearance(false).small())) } /// A text field, sunk into a well. pub(crate) fn text_field(input: &Entity, width: f32, _: &mut Window, cx: &App) -> Div { let p = metal::palette(cx); h_flex() .w(px(width)) .h(px(30.)) .px(px(11.)) .rounded(px(12.)) .well(p) .font_family(metal::MONO) .text_size(px(12.)) .text_color(p.ink) .child(div().flex_1().min_w_0().child(Input::new(input).appearance(false).small())) } /// A model dropdown across every enabled provider, with a first entry for /// "no model" (`none_label`: automatic, or off). `one_off` leaves out the /// agents that can't answer a one-off prompt. pub(crate) fn model_control( id: &'static str, current: Option, none_label: &'static str, one_off: bool, pick: impl Fn(Option, &mut Window, &mut App) + 'static, cx: &App, ) -> AnyElement { let catalog = Catalog::global(cx); let p = metal::palette(cx); let (icon, label): (Option, SharedString) = match ¤t { None => (None, none_label.into()), Some(key) => match catalog.find_key(key) { Some(model) => (Some(model.provider), model.name.clone().into()), None => { let (instance, model) = split_key(key).unwrap_or((key, key)); let kind = catalog.instance(instance).map(|state| state.kind()).or_else(|| Provider::from_key(instance)); (kind, model.to_string().into()) } }, }; // One menu section per enabled instance: its label, then its models. let groups: Vec<(String, Vec<(String, String)>, String)> = catalog .enabled() .filter(|state| !one_off || state.kind().answers_one_off()) .map(|state| { let models = state .models .iter() .filter(|model| !catalog.is_hidden(model)) .map(|model| { let name = match model.provider { Provider::Claude if model.legacy => format!("{} (legacy)", model.name), Provider::Claude => model.name.clone(), Provider::Pi | Provider::Jcode => format!("{} · {}", model.name, model.detail), }; (model.key(), name) }) .collect(); let note = match &state.status { ProbeStatus::Checking => "Asking for its models…".to_string(), ProbeStatus::Missing => "Not installed".to_string(), ProbeStatus::Failed(_) => "Didn't list its models".to_string(), _ => "No models".to_string(), }; (state.label(), models, note) }) .collect(); let empty = groups.is_empty(); let pick = Rc::new(pick); MetalButton::new(id) .compact() .map(|this| match icon { Some(provider) => this.icon(pickers::provider_icon(provider, px(13.), p)), None => this, }) .label(label) .caret() .max_w(px(260.)) .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| { let item = |key: Option| { let pick = pick.clone(); move |_: &ClickEvent, window: &mut Window, cx: &mut App| pick(key.clone(), window, cx) }; menu = menu .min_w(px(260.)) .max_h(px(440.)) .scrollable(true) .item(PopupMenuItem::new(none_label).checked(current.is_none()).on_click(item(None))); if empty { menu = menu.separator().item(PopupMenuItem::new("No providers available").disabled(true)); } for (label, models, note) in &groups { menu = menu.separator().label(label.clone()); if models.is_empty() { menu = menu.item(PopupMenuItem::new(note.clone()).disabled(true)); } for (key, name) in models { let checked = current.as_deref() == Some(key.as_str()); menu = menu.item(PopupMenuItem::new(name.clone()).checked(checked).on_click(item(Some(key.clone())))); } } menu }) .into_any_element() } /// A reasoning dropdown, with a first entry for "no level" (`none_label`). pub(crate) fn reasoning_control( id: &'static str, current: Option, none_label: &'static str, pick: impl Fn(Option, &mut Window, &mut App) + 'static, ) -> AnyElement { let mut choices = vec![Reasoning::Default]; choices.extend([Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max].map(Reasoning::Level)); choices.extend([Reasoning::Ultracode, Reasoning::Ultrathink]); let pick = Rc::new(pick); MetalButton::new(id) .compact() .icon(Lucide::Brain) .label(current.map(Reasoning::label).unwrap_or(none_label)) .caret() .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| { let item = |reasoning: Option| { let pick = pick.clone(); move |_: &ClickEvent, window: &mut Window, cx: &mut App| pick(reasoning, window, cx) }; menu = menu .min_w(px(220.)) .item(PopupMenuItem::new(none_label).checked(current.is_none()).on_click(item(None))) .separator(); for reasoning in choices.clone() { if reasoning == Reasoning::Default && none_label == "Default" { continue; } if reasoning == Reasoning::Ultracode { menu = menu.separator().label("Claude only"); } menu = menu.item( PopupMenuItem::new(reasoning.label()).checked(current == Some(reasoning)).on_click(item(Some(reasoning))), ); } menu }) .into_any_element() } /// A radio dot: a well, with a raised green bead when picked. pub(crate) fn radio(selected: bool, p: &Palette) -> Div { div() .flex() .flex_none() .items_center() .justify_center() .size(px(16.)) .rounded_full() .well(p) .when(selected, |this| this.child(div().size(px(8.)).rounded_full().bg(p.green_deep))) } /// Inline code, like T3 Code's version labels: `v2.1.0`. pub(crate) fn code(text: impl Into, p: &Palette) -> Div { div() .flex_none() .px(px(5.)) .rounded(px(5.)) .bg(p.ink.opacity(0.06)) .font_family(metal::MONO) .text_size(px(11.)) .text_color(p.ink2) .child(text.into()) } /// `2m ago`, `just now`. pub(crate) fn ago(time: std::time::SystemTime) -> String { let secs = time.elapsed().unwrap_or_default().as_secs(); match secs { 0..60 => "just now".into(), 60..3_600 => format!("{}m ago", secs / 60), 3_600..86_400 => format!("{}h ago", secs / 3_600), _ => format!("{}d ago", secs / 86_400), } }