use crate::{AppWindowFrame, TitleBar, WindowFrameMode};
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 ShellOverlayPosition {
TopLeft,
#[default]
TopRight,
BottomLeft,
BottomRight,
Center,
}
pub struct Shell {
id: SharedString,
mode: WindowFrameMode,
titlebar: Option<TitleBar>,
header: Option<AnyElement>,
sidebar: Option<AnyElement>,
right_sidebar: Option<AnyElement>,
main: Vec<AnyElement>,
footer: Option<AnyElement>,
overlays: Vec<AnyElement>,
overlay_position: ShellOverlayPosition,
overlay_inset: Pixels,
header_height: Option<Pixels>,
footer_height: Option<Pixels>,
main_scroll: bool,
main_padding: Option<Pixels>,
background: Option<Hsla>,
body_background: Option<Hsla>,
main_background: Option<Hsla>,
header_background: Option<Hsla>,
footer_background: Option<Hsla>,
header_border_color: Option<Hsla>,
footer_border_color: Option<Hsla>,
main_radius: Option<Pixels>,
body_gap: Option<Pixels>,
}
impl Shell {
pub fn new(content: impl IntoElement) -> Self {
Self {
id: "liora-shell".into(),
mode: WindowFrameMode::System,
titlebar: None,
header: None,
sidebar: None,
right_sidebar: None,
main: vec![content.into_any_element()],
footer: None,
overlays: Vec::new(),
overlay_position: ShellOverlayPosition::default(),
overlay_inset: px(16.0),
header_height: None,
footer_height: None,
main_scroll: false,
main_padding: None,
background: None,
body_background: None,
main_background: None,
header_background: None,
footer_background: None,
header_border_color: None,
footer_border_color: None,
main_radius: None,
body_gap: None,
}
}
pub fn id(mut self, id: impl Into<SharedString>) -> Self {
self.id = id.into();
self
}
pub fn mode(mut self, mode: WindowFrameMode) -> Self {
self.mode = mode;
self
}
pub fn titlebar(mut self, titlebar: TitleBar) -> Self {
self.titlebar = Some(titlebar);
self
}
pub fn header(mut self, header: impl IntoElement) -> Self {
self.header = Some(header.into_any_element());
self
}
pub fn sidebar(mut self, sidebar: impl IntoElement) -> Self {
self.sidebar = Some(sidebar.into_any_element());
self
}
pub fn right_sidebar(mut self, sidebar: impl IntoElement) -> Self {
self.right_sidebar = Some(sidebar.into_any_element());
self
}
pub fn main(mut self, content: impl IntoElement) -> Self {
self.main.push(content.into_any_element());
self
}
pub fn child(self, content: impl IntoElement) -> Self {
self.main(content)
}
pub fn footer(mut self, footer: impl IntoElement) -> Self {
self.footer = Some(footer.into_any_element());
self
}
pub fn overlay(mut self, overlay: impl IntoElement) -> Self {
self.overlays.push(overlay.into_any_element());
self
}
pub fn overlay_position(mut self, position: ShellOverlayPosition) -> Self {
self.overlay_position = position;
self
}
pub fn overlay_inset(mut self, inset: impl Into<Pixels>) -> Self {
self.overlay_inset = inset.into();
self
}
pub fn overlay_inset_units(self, inset: f32) -> Self {
self.overlay_inset(px(inset))
}
pub fn header_height(mut self, height: impl Into<Pixels>) -> Self {
self.header_height = Some(height.into());
self
}
pub fn header_height_units(self, height: f32) -> Self {
self.header_height(px(height))
}
pub fn footer_height(mut self, height: impl Into<Pixels>) -> Self {
self.footer_height = Some(height.into());
self
}
pub fn footer_height_units(self, height: f32) -> Self {
self.footer_height(px(height))
}
pub fn main_scroll(mut self) -> Self {
self.main_scroll = true;
self
}
pub fn main_padding(mut self, padding: impl Into<Pixels>) -> Self {
self.main_padding = Some(padding.into());
self
}
pub fn main_padding_units(self, padding: f32) -> Self {
self.main_padding(px(padding))
}
pub fn background(mut self, color: Hsla) -> Self {
self.background = Some(color);
self
}
pub fn body_background(mut self, color: Hsla) -> Self {
self.body_background = Some(color);
self
}
pub fn main_background(mut self, color: Hsla) -> Self {
self.main_background = Some(color);
self
}
pub fn header_background(mut self, color: Hsla) -> Self {
self.header_background = Some(color);
self
}
pub fn footer_background(mut self, color: Hsla) -> Self {
self.footer_background = Some(color);
self
}
pub fn header_border_color(mut self, color: Hsla) -> Self {
self.header_border_color = Some(color);
self
}
pub fn footer_border_color(mut self, color: Hsla) -> Self {
self.footer_border_color = Some(color);
self
}
pub fn main_rounded(mut self, radius: impl Into<Pixels>) -> Self {
self.main_radius = Some(radius.into());
self
}
pub fn main_rounded_units(self, radius: f32) -> Self {
self.main_rounded(px(radius))
}
pub fn body_gap(mut self, gap: impl Into<Pixels>) -> Self {
self.body_gap = Some(gap.into());
self
}
pub fn body_gap_units(self, gap: f32) -> Self {
self.body_gap(px(gap))
}
}
impl RenderOnce for Shell {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.global::<Config>().theme.clone();
let background = self.background.unwrap_or(theme.neutral.body);
let body_background = self.body_background.unwrap_or(background);
let main_background = self.main_background.unwrap_or(body_background);
let header_background = self.header_background.unwrap_or(background);
let footer_background = self.footer_background.unwrap_or(background);
let header_border = self.header_border_color.unwrap_or(theme.neutral.border);
let footer_border = self.footer_border_color.unwrap_or(theme.neutral.border);
let header_height = self.header_height.unwrap_or(px(48.0));
let footer_height = self.footer_height.unwrap_or(px(48.0));
let main_id = stable_unique_id(self.id.clone(), "shell-main", window, cx);
let mut root = div()
.id(self.id.clone())
.size_full()
.relative()
.flex()
.flex_col()
.bg(background);
if let Some(header) = self.header {
root = root.child(
div()
.flex_none()
.h(header_height)
.w_full()
.border_b_1()
.border_color(header_border)
.bg(header_background)
.flex()
.items_center()
.child(header),
);
}
let mut body = div()
.flex_1()
.min_h_0()
.flex()
.flex_row()
.bg(body_background)
.when_some(self.body_gap, |s, gap| s.gap(gap));
if let Some(sidebar) = self.sidebar {
body = body.child(sidebar);
}
body = body.child(
div()
.flex_1()
.min_w_0()
.min_h_0()
.h_full()
.id(main_id)
.flex()
.flex_col()
.bg(main_background)
.when_some(self.main_radius, |s, radius| s.rounded(radius))
.when(self.main_scroll, |s| s.overflow_y_scroll())
.when_some(self.main_padding, |s, padding| s.p(padding))
.children(self.main),
);
if let Some(right_sidebar) = self.right_sidebar {
body = body.child(right_sidebar);
}
root = root.child(body);
if let Some(footer) = self.footer {
root = root.child(
div()
.flex_none()
.h(footer_height)
.w_full()
.border_t_1()
.border_color(footer_border)
.bg(footer_background)
.flex()
.items_center()
.child(footer),
);
}
if !self.overlays.is_empty() {
root = root.child(positioned_overlays(
self.overlays,
self.overlay_position,
self.overlay_inset,
));
}
let content = root.into_any_element();
if self.mode.is_custom() || self.titlebar.is_some() {
AppWindowFrame::new("Liora", content)
.mode(self.mode)
.titlebar(self.titlebar.unwrap_or_else(TitleBar::new))
.into_any_element()
} else {
content
}
}
}
fn positioned_overlays(
overlays: Vec<AnyElement>,
position: ShellOverlayPosition,
inset: Pixels,
) -> AnyElement {
div()
.absolute()
.when(
matches!(
position,
ShellOverlayPosition::TopLeft | ShellOverlayPosition::TopRight
),
|s| s.top(inset),
)
.when(
matches!(
position,
ShellOverlayPosition::BottomLeft | ShellOverlayPosition::BottomRight
),
|s| s.bottom(inset),
)
.when(
matches!(
position,
ShellOverlayPosition::TopLeft | ShellOverlayPosition::BottomLeft
),
|s| s.left(inset),
)
.when(
matches!(
position,
ShellOverlayPosition::TopRight | ShellOverlayPosition::BottomRight
),
|s| s.right(inset),
)
.when(matches!(position, ShellOverlayPosition::Center), |s| {
s.top_0()
.left_0()
.size_full()
.flex()
.items_center()
.justify_center()
})
.children(overlays)
.into_any_element()
}
impl IntoElement for Shell {
type Element = Component<Self>;
fn into_element(self) -> Self::Element {
Component::new(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Sidebar;
#[test]
fn shell_builder_tracks_all_custom_regions() {
let shell = Shell::new("body")
.id("app-shell")
.mode(WindowFrameMode::Custom)
.titlebar(TitleBar::new().title("Liora"))
.header("header")
.sidebar(Sidebar::new())
.right_sidebar(Sidebar::new().right())
.main("main")
.footer("footer")
.overlay("overlay")
.overlay_position(ShellOverlayPosition::BottomRight)
.overlay_inset_units(20.0)
.header_height_units(56.0)
.footer_height_units(40.0)
.main_scroll()
.main_padding_units(24.0)
.background(gpui::transparent_black())
.body_background(gpui::transparent_black())
.main_background(gpui::transparent_black())
.header_background(gpui::transparent_black())
.footer_background(gpui::transparent_black())
.header_border_color(gpui::transparent_black())
.footer_border_color(gpui::transparent_black())
.main_rounded_units(18.0)
.body_gap_units(10.0);
assert_eq!(shell.id.as_ref(), "app-shell");
assert_eq!(shell.mode, WindowFrameMode::Custom);
assert!(shell.titlebar.is_some());
assert!(shell.header.is_some());
assert!(shell.sidebar.is_some());
assert!(shell.right_sidebar.is_some());
assert_eq!(shell.main.len(), 2);
assert!(shell.footer.is_some());
assert_eq!(shell.overlays.len(), 1);
assert_eq!(shell.overlay_position, ShellOverlayPosition::BottomRight);
assert_eq!(shell.overlay_inset, px(20.0));
assert_eq!(shell.header_height, Some(px(56.0)));
assert_eq!(shell.footer_height, Some(px(40.0)));
assert!(shell.main_scroll);
assert_eq!(shell.main_padding, Some(px(24.0)));
assert!(shell.background.is_some());
assert!(shell.body_background.is_some());
assert!(shell.main_background.is_some());
assert!(shell.header_background.is_some());
assert!(shell.footer_background.is_some());
assert!(shell.header_border_color.is_some());
assert!(shell.footer_border_color.is_some());
assert_eq!(shell.main_radius, Some(px(18.0)));
assert_eq!(shell.body_gap, Some(px(10.0)));
}
#[test]
fn shell_source_owns_shell_layout_primitives() {
let production = include_str!("shell.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
assert!(production.contains("AppWindowFrame::new"));
assert!(production.contains(".titlebar("));
assert!(production.contains(".flex_row()"));
assert!(production.contains("stable_unique_id"));
assert!(production.contains("shell-main"));
assert!(production.contains(".overflow_y_scroll()"));
assert!(production.contains("right_sidebar"));
assert!(production.contains("body_background"));
assert!(production.contains("main_background"));
assert!(production.contains("header_border_color"));
assert!(production.contains("footer_border_color"));
assert!(production.contains("main_radius"));
assert!(production.contains("ShellOverlayPosition"));
assert!(production.contains("positioned_overlays"));
assert!(production.contains(".absolute()"));
}
}