#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Rect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl Rect {
pub const ZERO: Self = Self::new(0.0, 0.0, 0.0, 0.0);
pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
Self {
x,
y,
width,
height,
}
}
pub fn right(self) -> f32 {
self.x + self.width
}
pub fn bottom(self) -> f32 {
self.y + self.height
}
pub fn is_empty(self) -> bool {
self.width <= 0.0 || self.height <= 0.0
}
pub fn inset(self, inset: Insets) -> Self {
let x = self.x + inset.left;
let y = self.y + inset.top;
let width = (self.width - inset.left - inset.right).max(0.0);
let height = (self.height - inset.top - inset.bottom).max(0.0);
Self::new(x, y, width, height)
}
pub fn contains(self, x: f32, y: f32) -> bool {
x >= self.x && x <= self.right() && y >= self.y && y <= self.bottom()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Insets {
pub top: f32,
pub right: f32,
pub bottom: f32,
pub left: f32,
}
impl Insets {
pub const fn all(value: f32) -> Self {
Self {
top: value,
right: value,
bottom: value,
left: value,
}
}
pub const fn symmetric(horizontal: f32, vertical: f32) -> Self {
Self {
top: vertical,
right: horizontal,
bottom: vertical,
left: horizontal,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AppLayout {
pub root: Rect,
pub header: Rect,
pub main: Rect,
pub sidebar: Rect,
pub prompt: Rect,
pub status: Rect,
pub sidebar_collapsed: bool,
}
pub fn compute_agent_layout(viewport: Rect) -> AppLayout {
let outer_gap = 16.0;
let inner_gap = 12.0;
let status_height = 32.0;
let prompt_height = if viewport.height < 680.0 { 92.0 } else { 126.0 };
let header_height = if viewport.height < 620.0 { 0.0 } else { 58.0 };
let sidebar_collapsed = viewport.width < 980.0;
let sidebar_width = if sidebar_collapsed {
0.0
} else {
(viewport.width * 0.27).clamp(300.0, 440.0)
};
let content = viewport.inset(Insets::all(outer_gap));
let header = Rect::new(content.x, content.y, content.width, header_height);
let status = Rect::new(
content.x,
content.bottom() - status_height,
content.width,
status_height,
);
let prompt = Rect::new(
content.x,
status.y - inner_gap - prompt_height,
content.width,
prompt_height,
);
let body_y = header.y + header.height + if header_height > 0.0 { inner_gap } else { 0.0 };
let body_h = (prompt.y - inner_gap - body_y).max(0.0);
let sidebar = if sidebar_collapsed {
Rect::ZERO
} else {
Rect::new(
content.right() - sidebar_width,
body_y,
sidebar_width,
body_h,
)
};
let main_width = if sidebar_collapsed {
content.width
} else {
(content.width - sidebar_width - inner_gap).max(0.0)
};
let main = Rect::new(content.x, body_y, main_width, body_h);
AppLayout {
root: viewport,
header,
main,
sidebar,
prompt,
status,
sidebar_collapsed,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_collapses_sidebar_on_small_width() {
let layout = compute_agent_layout(Rect::new(0.0, 0.0, 800.0, 700.0));
assert!(layout.sidebar_collapsed);
assert!(layout.sidebar.is_empty());
assert!(layout.main.width > 700.0);
}
#[test]
fn layout_keeps_prompt_and_status_visible() {
let layout = compute_agent_layout(Rect::new(0.0, 0.0, 1440.0, 900.0));
assert!(layout.main.height > 500.0);
assert!(layout.prompt.y > layout.main.y);
assert!(layout.status.y > layout.prompt.y);
}
}