use crate::core::buffer::Buffer;
use crate::core::buffer::Cell;
use crate::core::rect::Rect;
use crate::interaction::{
HitRegion, InteractionLayer, WidgetAction, WidgetId, WidgetRole, WidgetState,
};
use crate::sanitize;
use crate::theme::ThemeTokens;
use crate::widgets::block::{Block, BorderStyle};
use crate::widgets::Widget;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneCockpitSpec {
pub id: String,
pub title: String,
pub preferred_percent: u16,
pub min_width: u16,
pub min_height: u16,
pub focused: bool,
pub hidden: bool,
}
impl PaneCockpitSpec {
pub fn new(id: &str, title: &str) -> Self {
Self {
id: id.to_string(),
title: title.to_string(),
preferred_percent: 33,
min_width: 18,
min_height: 4,
focused: false,
hidden: false,
}
}
pub fn with_preferred_percent(mut self, percent: u16) -> Self {
self.preferred_percent = percent;
self
}
pub fn with_min_size(mut self, width: u16, height: u16) -> Self {
self.min_width = width;
self.min_height = height;
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn hidden(mut self, hidden: bool) -> Self {
self.hidden = hidden;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneCockpitLayout {
pub id: String,
pub outer: Rect,
pub inner: Rect,
}
#[derive(Debug, Clone)]
pub struct PaneCockpit {
pub header: String,
pub footer: String,
pub panes: Vec<PaneCockpitSpec>,
pub tokens: ThemeTokens,
pub stack_breakpoint: u16,
pub padding: Rect,
pub background_motion: bool,
pub frame: u64,
pub region_id: Option<WidgetId>,
}
impl PaneCockpit {
pub fn new() -> Self {
Self {
header: String::new(),
footer: String::new(),
panes: Vec::new(),
tokens: ThemeTokens::SCRIN,
stack_breakpoint: 90,
padding: Rect::new(1, 1, 1, 1),
background_motion: false,
frame: 0,
region_id: None,
}
}
pub fn with_header(mut self, header: &str) -> Self {
self.header = header.to_string();
self
}
pub fn with_footer(mut self, footer: &str) -> Self {
self.footer = footer.to_string();
self
}
pub fn with_pane(mut self, pane: PaneCockpitSpec) -> Self {
self.panes.push(pane);
self
}
pub fn with_tokens(mut self, tokens: ThemeTokens) -> Self {
self.tokens = tokens;
self
}
pub fn with_stack_breakpoint(mut self, width: u16) -> Self {
self.stack_breakpoint = width;
self
}
pub fn with_background_motion(mut self, enabled: bool, frame: u64) -> Self {
self.background_motion = enabled;
self.frame = frame;
self
}
pub fn with_region_id(mut self, id: impl Into<WidgetId>) -> Self {
self.region_id = Some(id.into());
self
}
pub fn pane_layouts(&self, area: Rect) -> Vec<PaneCockpitLayout> {
let content_top = if self.header.is_empty() {
area.y
} else {
area.y.saturating_add(1)
};
let content_bottom = if self.footer.is_empty() {
area.bottom()
} else {
area.bottom().saturating_sub(1)
};
let content = Rect::new(
area.x,
content_top,
area.width,
content_bottom.saturating_sub(content_top),
)
.inner(self.padding);
let visible = self
.panes
.iter()
.filter(|pane| !pane.hidden)
.collect::<Vec<_>>();
if visible.is_empty() || content.is_empty() {
return Vec::new();
}
if area.width < self.stack_breakpoint {
let mut layouts = Vec::new();
let mut y = content.y;
let remaining = content.height as usize;
let each = (remaining / visible.len()).max(1) as u16;
for (idx, pane) in visible.iter().enumerate() {
if y >= content.bottom() {
break;
}
let height = if idx + 1 == visible.len() {
content.bottom().saturating_sub(y)
} else {
each.max(pane.min_height)
.min(content.bottom().saturating_sub(y))
};
let outer = Rect::new(content.x, y, content.width, height);
layouts.push(PaneCockpitLayout {
id: pane.id.clone(),
outer,
inner: Block::inner_for_bordered(outer),
});
y = y.saturating_add(height);
}
return layouts;
}
let total_percent = visible
.iter()
.map(|pane| pane.preferred_percent.max(1) as usize)
.sum::<usize>()
.max(1);
let mut layouts = Vec::new();
let mut x = content.x;
for (idx, pane) in visible.iter().enumerate() {
if x >= content.right() {
break;
}
let width = if idx + 1 == visible.len() {
content.right().saturating_sub(x)
} else {
let preferred =
content.width.saturating_mul(pane.preferred_percent) / total_percent as u16;
preferred
.max(pane.min_width)
.min(content.right().saturating_sub(x))
};
let outer = Rect::new(x, content.y, width, content.height);
layouts.push(PaneCockpitLayout {
id: pane.id.clone(),
outer,
inner: Block::inner_for_bordered(outer),
});
x = x.saturating_add(width);
}
layouts
}
pub fn render_with_interaction(
&self,
buffer: &mut Buffer,
area: Rect,
layer: &mut InteractionLayer,
) -> Vec<PaneCockpitLayout> {
self.render(buffer, area);
let layouts = self.pane_layouts(area);
if area.is_empty() {
return layouts;
}
let region_id = self
.region_id
.clone()
.unwrap_or_else(|| WidgetId::new("pane-cockpit"));
layer.push_region(
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::Panel)
.with_label("pane cockpit"),
);
for layout in &layouts {
let Some(spec) = self.panes.iter().find(|pane| pane.id == layout.id) else {
continue;
};
layer.push_region(
HitRegion::new(
format!("{}:pane:{}", region_id.as_ref(), spec.id),
layout.outer,
)
.with_role(WidgetRole::Pane)
.with_label(spec.title.clone())
.with_action(WidgetAction::Focus)
.with_state(WidgetState::default().focused(spec.focused))
.with_z_index(1),
);
}
layouts
}
}
impl Default for PaneCockpit {
fn default() -> Self {
Self::new()
}
}
impl Widget for PaneCockpit {
fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
buffer.fill(area, ' ', self.tokens.text, Some(self.tokens.panel));
if self.background_motion {
for y in area.y as usize..area.bottom() as usize {
for x in area.x as usize..area.right() as usize {
if ((x as u64 + y as u64 + self.frame) % 17) == 0 {
buffer.set(
x,
y,
Cell::new('.', self.tokens.dim, Some(self.tokens.panel)),
);
}
}
}
}
if !self.header.is_empty() {
buffer.set_str(
area.x as usize,
area.y as usize,
&sanitize::truncate_str(&self.header, area.width as usize),
self.tokens.accent,
Some(self.tokens.panel),
);
}
if !self.footer.is_empty() {
buffer.set_str(
area.x as usize,
area.bottom().saturating_sub(1) as usize,
&sanitize::truncate_str(&self.footer, area.width as usize),
self.tokens.dim,
Some(self.tokens.panel),
);
}
for layout in self.pane_layouts(area) {
let Some(spec) = self.panes.iter().find(|pane| pane.id == layout.id) else {
continue;
};
let color = if spec.focused {
self.tokens.accent
} else {
self.tokens.dim
};
Block::new(&spec.title)
.with_borders(BorderStyle::Rounded)
.with_border_color(color)
.render(buffer, layout.outer);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pane_cockpit_stacks_when_narrow() {
let cockpit = PaneCockpit::new()
.with_pane(PaneCockpitSpec::new("a", "A"))
.with_pane(PaneCockpitSpec::new("b", "B"))
.with_stack_breakpoint(80);
let layouts = cockpit.pane_layouts(Rect::new(0, 0, 40, 12));
assert_eq!(layouts.len(), 2);
assert_eq!(layouts[0].outer.x, layouts[1].outer.x);
assert!(layouts[1].outer.y > layouts[0].outer.y);
}
}