use super::{dom_id, render_child};
use crate::grid::{ColumnView, DataGridView, Pin};
use crate::i18n::Text;
use crate::render::{Render, attr, escape};
use crate::screen::{Component, Node};
use serde::{Deserialize, Serialize};
use serde_json::{Value as Json, json};
super::layout::into_node!(Dock);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DockArea {
Left,
Right,
Bottom,
Center,
}
impl DockArea {
pub const ALL: [DockArea; 4] = [DockArea::Left, DockArea::Center, DockArea::Bottom, DockArea::Right];
pub fn code(self) -> &'static str {
match self {
DockArea::Left => "left",
DockArea::Right => "right",
DockArea::Bottom => "bottom",
DockArea::Center => "center",
}
}
fn parse(s: &str) -> Option<DockArea> {
DockArea::ALL.into_iter().find(|a| a.code() == s)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DockSizes {
pub left: u8,
pub right: u8,
pub bottom: u8,
}
impl Default for DockSizes {
fn default() -> Self {
DockSizes { left: 22, right: 24, bottom: 30 }
}
}
pub const DOCK_SIZE: (u8, u8) = (10, 60);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DockPanelState {
pub id: String,
pub area: DockArea,
pub open: bool,
#[serde(default)]
pub active: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DockLayout {
#[serde(default)]
pub sizes: DockSizes,
#[serde(default)]
pub panels: Vec<DockPanelState>,
}
const SIZE_PREFIX: &str = "size:";
const PANEL_PREFIX: &str = "panel:";
impl DockLayout {
pub fn clean(mut self, known: &[&str]) -> DockLayout {
let clamp = |v: u8| v.clamp(DOCK_SIZE.0, DOCK_SIZE.1);
self.sizes = DockSizes {
left: clamp(self.sizes.left),
right: clamp(self.sizes.right),
bottom: clamp(self.sizes.bottom),
};
let mut seen: Vec<String> = Vec::new();
self.panels.retain(|p| {
let keep = known.contains(&p.id.as_str()) && !seen.contains(&p.id);
if keep {
seen.push(p.id.clone());
}
keep
});
self
}
pub fn to_view(&self) -> DataGridView {
let size =
|name: &str, v: u8| ColumnView { field: format!("{SIZE_PREFIX}{name}"), width: Some(v.into()), pin: None };
let mut columns =
vec![size("left", self.sizes.left), size("right", self.sizes.right), size("bottom", self.sizes.bottom)];
columns.extend(self.panels.iter().map(|p| ColumnView {
field: format!("{PANEL_PREFIX}{}:{}", p.area.code(), p.id),
width: Some(p.open.into()),
pin: p.active.then_some(Pin::Start),
}));
DataGridView { columns, ..DataGridView::default() }
}
pub fn from_view(view: &DataGridView) -> Option<DockLayout> {
let mut layout = DockLayout::default();
for c in &view.columns {
if let Some(name) = c.field.strip_prefix(SIZE_PREFIX) {
let v = u8::try_from(c.width?).ok()?;
match name {
"left" => layout.sizes.left = v,
"right" => layout.sizes.right = v,
"bottom" => layout.sizes.bottom = v,
_ => return None,
}
} else {
let (area, id) = c.field.strip_prefix(PANEL_PREFIX)?.split_once(':')?;
layout.panels.push(DockPanelState {
id: id.to_string(),
area: DockArea::parse(area)?,
open: c.width.unwrap_or(1) > 0,
active: c.pin.is_some(),
});
}
}
Some(layout)
}
}
struct DockPanel {
id: &'static str,
title: Text,
area: DockArea,
}
pub struct Dock {
label: Text,
panels: Vec<DockPanel>,
nodes: Vec<Node>,
sizes: DockSizes,
}
impl Dock {
pub fn new(label: impl Into<Text>) -> Self {
Self { label: label.into(), panels: Vec::new(), nodes: Vec::new(), sizes: DockSizes::default() }
}
pub fn panel(mut self, area: DockArea, id: &'static str, title: impl Into<Text>, node: impl Into<Node>) -> Self {
self.panels.push(DockPanel { id, title: title.into(), area });
self.nodes.push(node.into());
self
}
pub fn left(self, id: &'static str, title: impl Into<Text>, node: impl Into<Node>) -> Self {
self.panel(DockArea::Left, id, title, node)
}
pub fn right(self, id: &'static str, title: impl Into<Text>, node: impl Into<Node>) -> Self {
self.panel(DockArea::Right, id, title, node)
}
pub fn bottom(self, id: &'static str, title: impl Into<Text>, node: impl Into<Node>) -> Self {
self.panel(DockArea::Bottom, id, title, node)
}
pub fn center(self, id: &'static str, title: impl Into<Text>, node: impl Into<Node>) -> Self {
self.panel(DockArea::Center, id, title, node)
}
pub fn sizes(mut self, left: u8, right: u8, bottom: u8) -> Self {
self.sizes = DockLayout { sizes: DockSizes { left, right, bottom }, panels: Vec::new() }.clean(&[]).sizes;
self
}
pub fn panel_ids(&self) -> Vec<&'static str> {
self.panels.iter().map(|p| p.id).collect()
}
}
impl Component for Dock {
fn render(&self, out: &mut String, r: &Render) {
let id = dom_id(r, "dock");
let t = |key: &str| r.text(&Text::key(format!("zeyn.ui.dock.{key}")));
let s = self.sizes;
out.push_str(&format!(
r#"<div class="z-dock" role="group" aria-label="{}" data-zeyn-dock="{id}" data-testid="{}.dock" style="--z-dock-left:{}%;--z-dock-right:{}%;--z-dock-bottom:{}%""#,
r.text(&self.label),
r.scope,
s.left,
s.right,
s.bottom
));
for key in [
"left", "right", "bottom", "center", "options", "move", "close", "show", "moved", "closed", "shown",
"empty",
] {
out.push_str(&format!(r#" data-text-{key}="{}""#, t(key)));
}
let titles: Vec<Json> = self.panels.iter().map(|p| json!({ "id": p.id, "title": r.text(&p.title) })).collect();
out.push_str(&format!(r#" data-panels="{}">"#, attr(&titles)));
for area in DockArea::ALL {
let code = area.code();
if area == DockArea::Bottom {
out.push_str(&self.bar(&id, "bottom", s.bottom, r));
}
if area == DockArea::Right {
out.push_str(&self.bar(&id, "right", s.right, r));
}
let mine: Vec<usize> = (0..self.panels.len()).filter(|i| self.panels[*i].area == area).collect();
let hidden = if mine.is_empty() && area != DockArea::Center { " hidden" } else { "" };
let area_name = t(code);
out.push_str(&format!(
r#"<section class="z-dock-area" data-area="{code}" aria-label="{area_name}"{hidden}><div class="z-dock-head"><div class="z-dock-tabs" role="tablist" aria-label="{area_name}">"#
));
for (n, i) in mine.iter().enumerate() {
let p = &self.panels[*i];
let pid = escape(p.id);
out.push_str(&format!(
r#"<button type="button" role="tab" class="z-dock-tab" id="{id}-tab-{pid}" aria-controls="{id}-panel-{pid}" aria-selected="{}" tabindex="{}" draggable="true" data-panel="{pid}" data-testid="{}.dock.{pid}">{}</button>"#,
n == 0,
if n == 0 { 0 } else { -1 },
r.scope,
r.text(&p.title)
));
}
out.push_str(&format!(
r#"</div><button type="button" class="z-dock-options" aria-haspopup="menu" aria-expanded="false" aria-label="{} ({area_name})" data-testid="{}.dock.options.{code}"><span aria-hidden="true">⋯</span></button></div><div class="z-dock-body">"#,
t("options"),
r.scope
));
for (n, i) in mine.iter().enumerate() {
let pid = escape(self.panels[*i].id);
let hidden = if n == 0 { "" } else { " hidden" };
out.push_str(&format!(
r#"<div class="z-dock-panel" role="tabpanel" id="{id}-panel-{pid}" aria-labelledby="{id}-tab-{pid}" tabindex="0" data-panel="{pid}"{hidden}>"#
));
render_child(out, r, *i, &self.nodes[*i]);
out.push_str("</div>");
}
out.push_str("</div></section>");
if area == DockArea::Left {
out.push_str(&self.bar(&id, "left", s.left, r));
}
}
out.push_str(r#"<p class="z-sr-only" aria-live="polite" data-zeyn-dock-live></p></div>"#);
}
fn schema(&self) -> Json {
let panels: Vec<Json> = self
.panels
.iter()
.zip(&self.nodes)
.map(|(p, n)| json!({ "id": p.id, "title": p.title, "area": p.area, "node": n.schema() }))
.collect();
json!({ "type": "dock", "label": self.label, "sizes": self.sizes, "panels": panels })
}
fn children(&self) -> &[Node] {
&self.nodes
}
}
impl Dock {
fn bar(&self, id: &str, edge: &str, value: u8, r: &Render) -> String {
let orientation = if edge == "bottom" { "horizontal" } else { "vertical" };
let label =
r.text(&Text::key("zeyn.ui.dock.resize").arg("area", r.text(&Text::key(format!("zeyn.ui.dock.{edge}")))));
format!(
r#"<div class="z-dock-bar" data-edge="{edge}" role="separator" tabindex="0" aria-orientation="{orientation}" aria-valuenow="{value}" aria-valuemin="{}" aria-valuemax="{}" aria-label="{label}" data-testid="{}.dock.resize.{edge}" data-id="{id}"></div>"#,
DOCK_SIZE.0, DOCK_SIZE.1, r.scope
)
}
}
pub(crate) fn panel_ids(schema: &Json) -> Option<Vec<String>> {
if schema.get("type")?.as_str()? != "dock" {
return None;
}
Some(schema.get("panels")?.as_array()?.iter().filter_map(|p| p.get("id")?.as_str().map(String::from)).collect())
}