use crate::{Card, Space, Text};
use gpui::{
AnyElement, App, Component, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels,
RenderOnce, SharedString, Styled, Window, div, prelude::*, px,
};
use liora_core::{Config, unique_id};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DockEdge {
Left,
Right,
Top,
Bottom,
Center,
}
pub struct DockPanel {
pub key: SharedString,
pub title: SharedString,
pub edge: DockEdge,
pub size: Option<Pixels>,
pub header: bool,
pub content: AnyElement,
}
impl DockPanel {
pub fn new(
key: impl Into<SharedString>,
title: impl Into<SharedString>,
edge: DockEdge,
content: impl IntoElement,
) -> Self {
Self {
key: key.into(),
title: title.into(),
edge,
size: None,
header: true,
content: content.into_any_element(),
}
}
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.size = Some(size.into().max(px(48.0)));
self
}
pub fn header(mut self, header: bool) -> Self {
self.header = header;
self
}
}
pub struct DockTab {
pub key: SharedString,
pub title: SharedString,
pub content: AnyElement,
pub closable: bool,
}
impl DockTab {
pub fn new(
key: impl Into<SharedString>,
title: impl Into<SharedString>,
content: impl IntoElement,
) -> Self {
Self {
key: key.into(),
title: title.into(),
content: content.into_any_element(),
closable: true,
}
}
pub fn closable(mut self, closable: bool) -> Self {
self.closable = closable;
self
}
}
pub struct DockLayout {
id: SharedString,
panels: Vec<DockPanel>,
tabs: Vec<DockTab>,
active_tab: Option<SharedString>,
height: Option<Pixels>,
bordered: bool,
panel_gap: Pixels,
}
impl DockLayout {
pub fn new() -> Self {
Self {
id: unique_id("dock-layout"),
panels: Vec::new(),
tabs: Vec::new(),
active_tab: None,
height: None,
bordered: true,
panel_gap: px(0.0),
}
}
pub fn id(mut self, id: impl Into<SharedString>) -> Self {
self.id = id.into();
self
}
pub fn panel(mut self, panel: DockPanel) -> Self {
self.panels.push(panel);
self
}
pub fn tab(mut self, tab: DockTab) -> Self {
if self.active_tab.is_none() {
self.active_tab = Some(tab.key.clone());
}
self.tabs.push(tab);
self
}
pub fn active_tab(mut self, key: impl Into<SharedString>) -> Self {
self.active_tab = Some(key.into());
self
}
pub fn height(mut self, height: impl Into<Pixels>) -> Self {
self.height = Some(height.into());
self
}
pub fn height_lg(self) -> Self {
self.height(px(520.0))
}
pub fn bordered(mut self, bordered: bool) -> Self {
self.bordered = bordered;
self
}
pub fn panel_gap(mut self, gap: impl Into<Pixels>) -> Self {
self.panel_gap = gap.into().max(px(0.0));
self
}
pub fn panel_count(&self, edge: DockEdge) -> usize {
self.panels
.iter()
.filter(|panel| panel.edge == edge)
.count()
}
pub fn active_tab_key(&self) -> Option<&SharedString> {
self.active_tab.as_ref()
}
}
impl IntoElement for DockLayout {
type Element = Component<Self>;
fn into_element(self) -> Self::Element {
Component::new(self)
}
}
impl RenderOnce for DockLayout {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.global::<Config>().theme.clone();
let active_tab_key = self.active_tab.clone();
let (top, remaining) = partition_panels(self.panels, DockEdge::Top);
let (bottom, remaining) = partition_panels(remaining, DockEdge::Bottom);
let (left, remaining) = partition_panels(remaining, DockEdge::Left);
let (right, center_panels) = partition_panels(remaining, DockEdge::Right);
let center = render_center(self.tabs, active_tab_key, center_panels, &theme);
let middle = div()
.flex()
.flex_row()
.flex_1()
.min_h(px(0.0))
.gap(self.panel_gap)
.children(left.into_iter().map(|panel| render_panel(panel, &theme)))
.child(center)
.children(right.into_iter().map(|panel| render_panel(panel, &theme)));
div()
.id(ElementId::from(self.id.clone()))
.flex()
.flex_col()
.w_full()
.overflow_hidden()
.when_some(self.height, |style, height| style.h(height))
.when(self.bordered, |style| {
style
.rounded_lg()
.border_1()
.border_color(theme.neutral.border)
})
.bg(theme.neutral.card)
.gap(self.panel_gap)
.children(top.into_iter().map(|panel| render_panel(panel, &theme)))
.child(middle)
.children(bottom.into_iter().map(|panel| render_panel(panel, &theme)))
}
}
fn partition_panels(panels: Vec<DockPanel>, edge: DockEdge) -> (Vec<DockPanel>, Vec<DockPanel>) {
panels.into_iter().partition(|panel| panel.edge == edge)
}
fn render_panel(panel: DockPanel, theme: &liora_theme::Theme) -> AnyElement {
let vertical = matches!(
panel.edge,
DockEdge::Left | DockEdge::Right | DockEdge::Center
);
let header = panel.header.then(|| {
div()
.px_3()
.py_2()
.border_b_1()
.border_color(theme.neutral.border)
.text_xs()
.font_weight(gpui::FontWeight::BOLD)
.text_color(theme.neutral.text_2)
.child(panel.title.clone())
});
div()
.flex()
.flex_col()
.min_w(px(0.0))
.min_h(px(0.0))
.when(vertical, |style| style.h_full())
.when(!vertical, |style| style.w_full())
.when_some(panel.size, |style, size| {
if vertical {
style.w(size).flex_shrink_0()
} else {
style.h(size).flex_shrink_0()
}
})
.border_1()
.border_color(theme.neutral.border)
.bg(theme.neutral.card)
.children(header)
.child(div().flex_1().min_h(px(0.0)).p_3().child(panel.content))
.into_any_element()
}
fn render_center(
tabs: Vec<DockTab>,
active_tab_key: Option<SharedString>,
center_panels: Vec<DockPanel>,
theme: &liora_theme::Theme,
) -> AnyElement {
if tabs.is_empty() {
return div()
.flex_1()
.min_w(px(0.0))
.h_full()
.child(
Space::new()
.vertical()
.gap_md()
.child(Text::new("No dock tabs"))
.child(Text::new(format!("{} center panels", center_panels.len())).sm()),
)
.into_any_element();
}
let active_index = tabs
.iter()
.position(|tab| Some(&tab.key) == active_tab_key.as_ref())
.unwrap_or(0);
let mut active_content = None;
let mut headers = div()
.flex()
.items_center()
.border_b_1()
.border_color(theme.neutral.border);
for (index, tab) in tabs.into_iter().enumerate() {
let active = index == active_index;
headers = headers.child(
div()
.px_3()
.py_2()
.text_sm()
.font_weight(if active {
gpui::FontWeight::BOLD
} else {
gpui::FontWeight::NORMAL
})
.text_color(if active {
theme.primary.base
} else {
theme.neutral.text_2
})
.bg(if active {
theme.primary.light_9
} else {
theme.neutral.card
})
.child(tab.title.clone()),
);
if active {
active_content = Some(tab.content);
}
}
div()
.flex_1()
.min_w(px(0.0))
.h_full()
.child(
Card::new(
div().flex().flex_col().size_full().child(headers).child(
div()
.flex_1()
.min_h(px(0.0))
.p_3()
.child(active_content.unwrap_or_else(|| div().into_any_element())),
),
)
.no_shadow(),
)
.into_any_element()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dock_center_card_fills_remaining_middle_width() {
let source = include_str!("dock_layout.rs");
assert!(source.contains(
".flex_1()
.min_w(px(0.0))
.h_full()"
));
assert!(source.contains(
".child(
Card::new"
));
}
#[test]
fn dock_layout_tracks_panels_tabs_and_active_tab() {
let layout = DockLayout::new()
.panel(DockPanel::new("explorer", "Explorer", DockEdge::Left, div()).size(px(220.0)))
.panel(DockPanel::new("terminal", "Terminal", DockEdge::Bottom, div()).size(px(160.0)))
.tab(DockTab::new("main", "main.rs", div()).closable(false))
.tab(DockTab::new("readme", "README.md", div()))
.active_tab("readme")
.height_lg()
.panel_gap(px(6.0));
assert_eq!(layout.panel_count(DockEdge::Left), 1);
assert_eq!(layout.panel_count(DockEdge::Bottom), 1);
assert_eq!(
layout.active_tab_key().map(|key| key.as_ref()),
Some("readme")
);
assert_eq!(layout.height, Some(px(520.0)));
}
#[test]
fn dock_panel_clamps_tiny_sizes() {
let panel = DockPanel::new("outline", "Outline", DockEdge::Right, div()).size(px(1.0));
assert_eq!(panel.size, Some(px(48.0)));
}
}