//! Turns sidebar rows into elements. Decides nothing. //! //! One row anatomy, applied to every row including headers, so the columns line //! up down the rail and nothing shifts when a section grows: //! //! ```text //! [disclosure 12][indicator 12][kind 14][label flex_1 truncate][meta][⋯] //! ``` //! //! Every slot is reserved even when empty. A row that omits its indicator //! column would pull its label left and break the vertical line the eye follows. //! //! One selection language, everywhere: rest is transparent, hover is //! `surface_raised`, the selected pane is `surface_active` with a two-pixel //! accent rail, and the keyboard cursor is a one-pixel border. Before this the //! attention cards *darkened* on hover while the worktree rows *lightened*, so //! the same gesture meant two different things a hundred pixels apart. use gpui::prelude::*; use gpui::{Rgba, div, px, relative}; use super::model::{Indicator, RowBody, RowKey, SidebarRow}; use crate::theme::ThemeColors; /// Widths of the fixed slots. const DISCLOSURE_WIDTH: f32 = 12.0; const INDICATOR_WIDTH: f32 = 12.0; const KIND_WIDTH: f32 = 14.0; /// The permille at which a quota stops being background information. pub(crate) const CRITICAL_PERMILLE: u64 = 900; /// What the caller needs to know to paint one row. #[derive(Clone, Copy)] pub(crate) struct RowStyle { pub(crate) theme: ThemeColors, /// The keyboard cursor is here. pub(crate) cursored: bool, /// The sidebar owns the keyboard, so the cursor is drawn in the accent. pub(crate) focused: bool, } /// The colour an indicator is drawn in. /// /// By meaning, not by value: a faulted pane is the danger colour because it is /// broken, not because a number crossed a line. const fn indicator_color(indicator: Indicator, theme: ThemeColors) -> Rgba { match indicator { Indicator::Faulted | Indicator::FinishedFailing => theme.danger, Indicator::Waiting | Indicator::QuotaNearlySpent => theme.attention, Indicator::FinishedCleanly => theme.success, Indicator::Working => theme.accent, Indicator::None => theme.muted, } } /// A fixed-width cell, so every row's columns align. fn slot(width: f32) -> gpui::Div { div().w(px(width)).flex_none() } /// The quota chip. /// /// Renders the whole headline string or nothing at all. `headline` is what is /// *left* while the meter fills with what is *spent*; a bare "82" beside a /// nearly-empty gauge would assert the exact opposite of the fact. fn quota_chip(headline: Option<&str>, critical: bool, theme: ThemeColors) -> gpui::AnyElement { let Some(headline) = headline else { // The same hairline the footer uses for no reading. A full-length empty // gauge reads as "plenty left" from across the room. return div() .w(px(28.0)) .h(px(2.0)) .flex_none() .bg(theme.border) .into_any_element(); }; div() .flex_none() .px(px(4.0)) .rounded(px(3.0)) .text_xs() .bg(theme.surface_raised) .text_color(if critical { theme.attention } else { theme.muted }) .child(headline.to_owned()) .into_any_element() } /// Paints one row. #[allow( clippy::too_many_lines, reason = "one declarative element tree per row kind; splitting hides the shared anatomy" )] pub(crate) fn row(row: &SidebarRow, style: RowStyle) -> gpui::AnyElement { let theme = style.theme; let selected = matches!(&row.body, RowBody::Pane(pane) if pane.selected); let indent = f32::from(row.depth) * 10.0; let base = div() .flex() .items_center() .h(px(super::model::ROW_HEIGHT)) .w_full() .pl(px(6.0 + indent)) .pr(px(6.0)) .gap(px(4.0)) .when(selected, |view| view.bg(theme.surface_active)) .when(style.cursored, |view| { view.border_color(if style.focused { theme.accent } else { theme.border }) }) // A transparent border on every row, always: without it a cursored row // is one pixel taller than its neighbours and the list jitters as the // cursor moves. .border_1() .when(!style.cursored, |view| { view.border_color(gpui::transparent_black()) }); match &row.body { RowBody::Header { section, count, collapsed, } => base .child( slot(DISCLOSURE_WIDTH) .text_xs() .text_color(theme.muted) .child(if *collapsed { "›" } else { "⌄" }), ) .child( div() .flex_1() .min_w_0() .text_xs() .text_color(theme.muted) .child(section.title()), ) .children(count.map(|count| { div() .flex_none() .text_xs() .text_color(theme.muted) .child(format!("{count}")) })) .into_any_element(), RowBody::Empty(message) => base .child(slot(DISCLOSURE_WIDTH)) .child( div() .flex_1() .min_w_0() .truncate() .text_xs() .text_color(theme.muted) .child((*message).to_owned()), ) .into_any_element(), RowBody::Attention(entry) => base .child(slot(DISCLOSURE_WIDTH)) .child( slot(INDICATOR_WIDTH) .text_xs() // A guess is drawn quietly. Decision 0019 lets it show and // sort, but it must not look like a report. .text_color(if entry.countable { theme.attention } else { theme.muted }) .child(if entry.countable { "●" } else { "·" }), ) .child(slot(KIND_WIDTH)) // A floor under the title. Shrinking proportionally is not enough: // the reason is the longer string, so flexbox happily reduced the // title to two characters and the row stopped saying which pane it // was about. .child( div() .flex_1() .min_w(px(70.0)) .truncate() .text_sm() .text_color(theme.text) .child(entry.title.clone()), ) // The reason yields before the title does. A row reading // "Te… Exited with code 7 · observed" has thrown away the one word // that says which pane to look at. .child( div() .flex_shrink() .min_w(px(0.0)) .truncate() .text_xs() .text_color(theme.muted) .child(format!("{} · {}", entry.reason, entry.source)), ) .into_any_element(), RowBody::Pane(pane) => base .child(slot(DISCLOSURE_WIDTH)) .child( slot(INDICATOR_WIDTH) .text_xs() .text_color(indicator_color(pane.indicator, theme)) .child(pane.indicator.glyph()), ) .child( slot(KIND_WIDTH) .text_xs() .text_color(theme.muted) .child(pane.kind_glyph), ) .child( div() .flex_1() .min_w_0() .truncate() .text_sm() .text_color(if selected { theme.text } else { theme.muted }) .child(pane.title.clone()), ) .child(quota_chip( pane.quota.as_deref(), pane.quota_critical, theme, )) .into_any_element(), RowBody::Host(host) => base .child(slot(DISCLOSURE_WIDTH)) .child( slot(INDICATOR_WIDTH) .text_xs() .text_color(if host.live { theme.success } else { theme.muted }) .child(if host.live { "●" } else { "○" }), ) .child(slot(KIND_WIDTH)) .child( div() .flex_1() .min_w_0() .truncate() .text_sm() .text_color(theme.muted) .child(host.name.clone()), ) .child( div() .flex_shrink() .min_w(px(0.0)) .truncate() .text_xs() .text_color(theme.muted) .child(host.detail.clone()), ) .into_any_element(), RowBody::Quota(entry) => base .child(slot(DISCLOSURE_WIDTH)) .child(slot(INDICATOR_WIDTH)) .child(slot(KIND_WIDTH)) .child( div() .flex_1() .min_w_0() .truncate() .text_xs() .text_color(theme.muted) .child(entry.label.clone()), ) .child(meter(entry.consumed_permille, theme)) .child(quota_chip(entry.headline.as_deref(), entry.critical, theme)) .into_any_element(), } } /// A quota gauge. Filled with what is spent, which is the way round the footer /// draws it, so the two cannot disagree. fn meter(consumed_permille: Option, theme: ThemeColors) -> gpui::AnyElement { let Some(permille) = consumed_permille else { return div() .w(px(32.0)) .h(px(2.0)) .flex_none() .bg(theme.border) .into_any_element(); }; let clamped = u16::try_from(permille.min(1_000)).unwrap_or(1_000); div() .w(px(32.0)) .h(px(5.0)) .flex_none() .rounded(px(2.0)) .bg(theme.border_quiet) .overflow_hidden() .child(div().h_full().w(relative(f32::from(clamped) / 1_000.0)).bg( if permille >= CRITICAL_PERMILLE { theme.attention } else { theme.accent }, )) .into_any_element() } /// A stable element id for a row. pub(crate) fn element_id(key: &RowKey) -> gpui::ElementId { match key { RowKey::Header(section) => ("sidebar-header", u64::from(*section as u32)).into(), RowKey::Empty(section) => ("sidebar-empty", u64::from(*section as u32)).into(), RowKey::Attention(id) => ("sidebar-attention", *id).into(), RowKey::Pane(id) => ("sidebar-pane", *id).into(), RowKey::Detached(id) => ("sidebar-detached", *id).into(), RowKey::Host(index) => ("sidebar-host", u64::try_from(*index).unwrap_or(0)).into(), RowKey::Quota(id) => gpui::ElementId::Name(format!("sidebar-quota-{id}").into()), } }