use crate::{Space, Text};
use gpui::{
AnyElement, App, Component, Hsla, IntoElement, Pixels, RenderOnce, SharedString, Window, div,
prelude::*, px,
};
use liora_core::{Config, stable_unique_id};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SidebarPosition {
#[default]
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SidebarCollapseMode {
#[default]
Full,
IconsOnly,
Hidden,
}
pub struct Sidebar {
id: SharedString,
position: SidebarPosition,
collapse_mode: SidebarCollapseMode,
expanded_width: Pixels,
collapsed_width: Pixels,
min_width: Pixels,
max_width: Pixels,
resizable: bool,
scrollable: bool,
show_border: bool,
background: Option<Hsla>,
border_color: Option<Hsla>,
radius: Option<Pixels>,
header_padding: Pixels,
content_padding: Pixels,
footer_padding: Pixels,
gap: Pixels,
logo: Option<AnyElement>,
brand_title: Option<SharedString>,
brand_subtitle: Option<SharedString>,
brand_action: Option<AnyElement>,
header: Option<AnyElement>,
content: Vec<AnyElement>,
footer: Option<AnyElement>,
}
impl Sidebar {
pub fn new() -> Self {
Self {
id: "liora-sidebar".into(),
position: SidebarPosition::Left,
collapse_mode: SidebarCollapseMode::Full,
expanded_width: px(280.0),
collapsed_width: px(64.0),
min_width: px(180.0),
max_width: px(420.0),
resizable: false,
scrollable: false,
show_border: true,
background: None,
border_color: None,
radius: None,
header_padding: px(0.0),
content_padding: px(0.0),
footer_padding: px(0.0),
gap: px(0.0),
logo: None,
brand_title: None,
brand_subtitle: None,
brand_action: None,
header: None,
content: Vec::new(),
footer: None,
}
}
pub fn id(mut self, id: impl Into<SharedString>) -> Self {
self.id = id.into();
self
}
pub fn left(mut self) -> Self {
self.position = SidebarPosition::Left;
self
}
pub fn right(mut self) -> Self {
self.position = SidebarPosition::Right;
self
}
pub fn position(mut self, position: SidebarPosition) -> Self {
self.position = position;
self
}
pub fn collapse_mode(mut self, mode: SidebarCollapseMode) -> Self {
self.collapse_mode = mode;
self
}
pub fn expanded_width(mut self, width: impl Into<Pixels>) -> Self {
self.expanded_width = width.into();
self
}
pub fn expanded_width_units(self, width: f32) -> Self {
self.expanded_width(px(width))
}
pub fn collapsed_width(mut self, width: impl Into<Pixels>) -> Self {
self.collapsed_width = width.into();
self
}
pub fn collapsed_width_units(self, width: f32) -> Self {
self.collapsed_width(px(width))
}
pub fn min_width(mut self, width: impl Into<Pixels>) -> Self {
self.min_width = width.into();
self
}
pub fn min_width_units(self, width: f32) -> Self {
self.min_width(px(width))
}
pub fn max_width(mut self, width: impl Into<Pixels>) -> Self {
self.max_width = width.into();
self
}
pub fn max_width_units(self, width: f32) -> Self {
self.max_width(px(width))
}
pub fn resizable(mut self) -> Self {
self.resizable = true;
self
}
pub fn scrollable(mut self) -> Self {
self.scrollable = true;
self
}
pub fn brand(mut self, title: impl Into<SharedString>) -> Self {
self.brand_title = Some(title.into());
self
}
pub fn brand_subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
self.brand_subtitle = Some(subtitle.into());
self
}
pub fn logo(mut self, logo: impl IntoElement) -> Self {
self.logo = Some(logo.into_any_element());
self
}
pub fn brand_action(mut self, action: impl IntoElement) -> Self {
self.brand_action = Some(action.into_any_element());
self
}
pub fn header_padding(mut self, padding: impl Into<Pixels>) -> Self {
self.header_padding = padding.into();
self
}
pub fn header_padding_units(self, padding: f32) -> Self {
self.header_padding(px(padding))
}
pub fn content_padding(mut self, padding: impl Into<Pixels>) -> Self {
self.content_padding = padding.into();
self
}
pub fn content_padding_units(self, padding: f32) -> Self {
self.content_padding(px(padding))
}
pub fn footer_padding(mut self, padding: impl Into<Pixels>) -> Self {
self.footer_padding = padding.into();
self
}
pub fn footer_padding_units(self, padding: f32) -> Self {
self.footer_padding(px(padding))
}
pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
self.gap = gap.into();
self
}
pub fn gap_units(self, gap: f32) -> Self {
self.gap(px(gap))
}
pub fn background(mut self, color: Hsla) -> Self {
self.background = Some(color);
self
}
pub fn border_color(mut self, color: Hsla) -> Self {
self.border_color = Some(color);
self
}
pub fn border(mut self, show: bool) -> Self {
self.show_border = show;
self
}
pub fn rounded(mut self, radius: impl Into<Pixels>) -> Self {
self.radius = Some(radius.into());
self
}
pub fn rounded_units(self, radius: f32) -> Self {
self.rounded(px(radius))
}
pub fn header(mut self, header: impl IntoElement) -> Self {
self.header = Some(header.into_any_element());
self
}
pub fn child(mut self, child: impl IntoElement) -> Self {
self.content.push(child.into_any_element());
self
}
pub fn content(self, child: impl IntoElement) -> Self {
self.child(child)
}
pub fn children(mut self, children: impl IntoIterator<Item = impl IntoElement>) -> Self {
self.content
.extend(children.into_iter().map(IntoElement::into_any_element));
self
}
pub fn footer(mut self, footer: impl IntoElement) -> Self {
self.footer = Some(footer.into_any_element());
self
}
fn current_width(&self) -> Pixels {
match self.collapse_mode {
SidebarCollapseMode::Full => self.expanded_width,
SidebarCollapseMode::IconsOnly => self.collapsed_width,
SidebarCollapseMode::Hidden => px(0.0),
}
}
}
impl Default for Sidebar {
fn default() -> Self {
Self::new()
}
}
impl RenderOnce for Sidebar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.global::<Config>().theme.clone();
let width = self.current_width();
let hidden = matches!(self.collapse_mode, SidebarCollapseMode::Hidden);
let right = matches!(self.position, SidebarPosition::Right);
let content_id = stable_unique_id(self.id.clone(), "sidebar-content", window, cx);
let background = self.background.unwrap_or(theme.neutral.card);
let border_color = self.border_color.unwrap_or(theme.neutral.border);
let header = self.header.or_else(|| {
(self.logo.is_some() || self.brand_title.is_some() || self.brand_subtitle.is_some())
.then(|| {
brand_header(
self.logo,
self.brand_title,
self.brand_subtitle,
self.brand_action,
)
})
});
div()
.id(self.id)
.flex_none()
.h_full()
.min_h_0()
.w(width)
.when(hidden, |s| s.hidden())
.when(!hidden, |s| {
s.bg(background)
.when_some(self.radius, |s, radius| s.rounded(radius))
.when(self.show_border && !right, |s| {
s.border_r_1().border_color(border_color)
})
.when(self.show_border && right, |s| {
s.border_l_1().border_color(border_color)
})
})
.flex()
.flex_col()
.gap(self.gap)
.child(
div()
.flex_none()
.p(self.header_padding)
.when_some(header, |s, header| s.child(header)),
)
.child(
div()
.id(content_id)
.flex_1()
.min_h_0()
.p(self.content_padding)
.when(self.scrollable, |s| s.overflow_y_scroll())
.children(self.content),
)
.child(
div()
.flex_none()
.p(self.footer_padding)
.when_some(self.footer, |s, footer| s.child(footer)),
)
}
}
fn brand_header(
logo: Option<AnyElement>,
title: Option<SharedString>,
subtitle: Option<SharedString>,
action: Option<AnyElement>,
) -> AnyElement {
Space::new()
.gap_sm()
.shrink()
.child(logo.unwrap_or_else(|| div().into_any_element()))
.child(
Space::new()
.vertical()
.gap_xs()
.grow()
.shrink()
.when_some(title, |s, title| s.child(Text::new(title).bold().nowrap()))
.when_some(subtitle, |s, subtitle| {
s.child(Text::new(subtitle).sm().nowrap())
}),
)
.when_some(action, |s, action| s.child(action))
.into_any_element()
}
impl IntoElement for Sidebar {
type Element = Component<Self>;
fn into_element(self) -> Self::Element {
Component::new(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sidebar_builder_tracks_layout_state() {
let sidebar = Sidebar::new()
.id("nav")
.right()
.collapse_mode(SidebarCollapseMode::IconsOnly)
.expanded_width_units(300.0)
.collapsed_width_units(72.0)
.min_width_units(200.0)
.max_width_units(480.0)
.resizable()
.scrollable()
.header("header")
.child("content")
.footer("footer");
assert_eq!(sidebar.id.as_ref(), "nav");
assert_eq!(sidebar.position, SidebarPosition::Right);
assert_eq!(sidebar.collapse_mode, SidebarCollapseMode::IconsOnly);
assert_eq!(sidebar.current_width(), px(72.0));
assert_eq!(sidebar.expanded_width, px(300.0));
assert_eq!(sidebar.min_width, px(200.0));
assert_eq!(sidebar.max_width, px(480.0));
assert!(sidebar.resizable);
assert!(sidebar.scrollable);
assert!(sidebar.header.is_some());
assert_eq!(sidebar.content.len(), 1);
assert!(sidebar.footer.is_some());
}
#[test]
fn sidebar_builder_tracks_brand_and_high_customization_options() {
let sidebar = Sidebar::new()
.brand("Liora")
.brand_subtitle("Native GPUI")
.logo("logo")
.brand_action("action")
.header_padding_units(14.0)
.content_padding_units(10.0)
.footer_padding_units(12.0)
.gap_units(8.0)
.background(gpui::transparent_black())
.border_color(gpui::transparent_black())
.border(false)
.rounded_units(18.0)
.content("secondary")
.footer("footer");
assert_eq!(sidebar.brand_title.as_deref(), Some("Liora"));
assert_eq!(sidebar.brand_subtitle.as_deref(), Some("Native GPUI"));
assert!(sidebar.logo.is_some());
assert!(sidebar.brand_action.is_some());
assert_eq!(sidebar.header_padding, px(14.0));
assert_eq!(sidebar.content_padding, px(10.0));
assert_eq!(sidebar.footer_padding, px(12.0));
assert_eq!(sidebar.gap, px(8.0));
assert!(sidebar.background.is_some());
assert!(sidebar.border_color.is_some());
assert!(!sidebar.show_border);
assert_eq!(sidebar.radius, Some(px(18.0)));
assert_eq!(sidebar.content.len(), 1);
assert!(sidebar.footer.is_some());
}
#[test]
fn sidebar_source_owns_scroll_and_border_layout() {
let production = include_str!("sidebar.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
assert!(production.contains("stable_unique_id"));
assert!(production.contains("overflow_y_scroll"));
assert!(production.contains("border_r_1"));
assert!(production.contains("border_l_1"));
assert!(production.contains("theme.neutral.card"));
assert!(production.contains("theme.neutral.border"));
assert!(production.contains("SidebarCollapseMode::Hidden"));
}
}