Skip to main content

agpu/widget/
tabs.rs

1//! Tabs and Panel widgets.
2
3use crate::core::{Color, Position, Rect, TextStyle};
4use crate::ontology::{
5    AgentAction, AgentCapability, Discoverable, SemanticRole, UiNode, WidgetSchema,
6};
7use crate::paint::Painter;
8use crate::widget::Widget;
9
10/// A tabbed container — shows one tab's content at a time.
11pub struct Tabs {
12    pub id: String,
13    pub labels: Vec<String>,
14    pub active: usize,
15    bg_color: Option<Color>,
16    fg_color: Option<Color>,
17    corner_radius: Option<f32>,
18    font_size: Option<f32>,
19    is_bold: bool,
20}
21
22impl Tabs {
23    #[must_use]
24    pub fn new(id: impl Into<String>, labels: Vec<String>, active: usize) -> Self {
25        Self {
26            id: id.into(),
27            labels,
28            active,
29            bg_color: None,
30            fg_color: None,
31            corner_radius: None,
32            font_size: None,
33            is_bold: false,
34        }
35    }
36
37    #[must_use]
38    pub fn bg(mut self, color: Color) -> Self {
39        self.bg_color = Some(color);
40        self
41    }
42
43    #[must_use]
44    pub fn fg(mut self, color: Color) -> Self {
45        self.fg_color = Some(color);
46        self
47    }
48
49    #[must_use]
50    pub fn rounded(mut self, radius: f32) -> Self {
51        self.corner_radius = Some(radius);
52        self
53    }
54
55    #[must_use]
56    pub fn text_size(mut self, size: f32) -> Self {
57        self.font_size = Some(size);
58        self
59    }
60
61    #[must_use]
62    pub fn bold(mut self) -> Self {
63        self.is_bold = true;
64        self
65    }
66}
67
68impl Widget for Tabs {
69    fn draw(&self, painter: &mut dyn Painter, area: Rect) {
70        let tab_height = 32.0;
71        let tab_width = if self.labels.is_empty() {
72            area.width
73        } else {
74            area.width / self.labels.len() as f32
75        };
76
77        // Tab bar background
78        let tab_bar = Rect::new(area.x, area.y, area.width, tab_height);
79        let bar_bg = self.bg_color.unwrap_or(Color::rgba(0.15, 0.15, 0.18, 1.0));
80        painter.fill_rect(tab_bar, bar_bg, 0.0);
81
82        let style = TextStyle {
83            font_size: self.font_size.unwrap_or(13.0),
84            color: self.fg_color.unwrap_or(Color::WHITE),
85            ..TextStyle::default()
86        };
87
88        for (i, label) in self.labels.iter().enumerate() {
89            let tab_rect = Rect::new(area.x + i as f32 * tab_width, area.y, tab_width, tab_height);
90
91            if i == self.active {
92                painter.fill_rect(tab_rect, Color::rgba(0.2, 0.2, 0.25, 1.0), 0.0);
93                // Active indicator
94                let indicator =
95                    Rect::new(tab_rect.x, tab_rect.y + tab_height - 2.0, tab_width, 2.0);
96                painter.fill_rect(indicator, Color::rgba(0.3, 0.6, 1.0, 1.0), 0.0);
97            }
98
99            let text_color = if i == self.active {
100                Color::WHITE
101            } else {
102                Color::rgba(0.6, 0.6, 0.7, 1.0)
103            };
104
105            let tab_style = TextStyle {
106                color: text_color,
107                ..style.clone()
108            };
109
110            let text_size = painter.measure_text(label, &tab_style);
111            painter.text(
112                Position::new(
113                    tab_rect.x + (tab_width - text_size.width) * 0.5,
114                    tab_rect.y + (tab_height - text_size.height) * 0.5,
115                ),
116                label,
117                &tab_style,
118            );
119        }
120
121        // Content area
122        let content = Rect::new(
123            area.x,
124            area.y + tab_height,
125            area.width,
126            area.height - tab_height,
127        );
128        painter.fill_rect(content, Color::rgba(0.1, 0.1, 0.13, 1.0), 0.0);
129    }
130
131    fn ui_node(&self) -> UiNode {
132        UiNode::new("Tabs", SemanticRole::Tab).with_id(&self.id)
133    }
134}
135
136impl Discoverable for Tabs {
137    fn schema(&self) -> WidgetSchema {
138        WidgetSchema::new("Tabs", "A tabbed container", SemanticRole::Tab)
139    }
140
141    fn capabilities(&self) -> Vec<AgentCapability> {
142        vec![
143            AgentCapability::Focusable,
144            AgentCapability::Selectable {
145                multi_select: false,
146                item_count: self.labels.len(),
147            },
148        ]
149    }
150
151    fn actions(&self) -> Vec<AgentAction> {
152        vec![AgentAction::simple(
153            "select_tab",
154            "Switch to a tab by index",
155            true,
156        )]
157    }
158
159    fn semantic_role(&self) -> SemanticRole {
160        SemanticRole::Tab
161    }
162
163    fn agent_state(&self) -> serde_json::Value {
164        serde_json::json!({
165            "active": self.active,
166            "tab_count": self.labels.len(),
167            "labels": self.labels,
168        })
169    }
170
171    fn execute_action(
172        &mut self,
173        action: &str,
174        params: &serde_json::Value,
175    ) -> Result<serde_json::Value, String> {
176        match action {
177            "select_tab" => {
178                if let Some(idx) = params.get("index").and_then(|v| v.as_u64()) {
179                    let idx = idx as usize;
180                    if idx < self.labels.len() {
181                        self.active = idx;
182                        Ok(serde_json::json!({ "active": idx }))
183                    } else {
184                        Err("Tab index out of range".into())
185                    }
186                } else {
187                    Err("Missing 'index' parameter".into())
188                }
189            }
190            _ => Err(format!("Unknown action: {action}")),
191        }
192    }
193
194    fn agent_id(&self) -> Option<&str> {
195        Some(&self.id)
196    }
197}
198
199/// A collapsible panel container.
200pub struct Panel {
201    pub id: String,
202    pub title: String,
203    pub collapsed: bool,
204    bg_color: Option<Color>,
205    fg_color: Option<Color>,
206    corner_radius: Option<f32>,
207    font_size: Option<f32>,
208    is_bold: bool,
209}
210
211impl Panel {
212    #[must_use]
213    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
214        Self {
215            id: id.into(),
216            title: title.into(),
217            collapsed: false,
218            bg_color: None,
219            fg_color: None,
220            corner_radius: None,
221            font_size: None,
222            is_bold: false,
223        }
224    }
225
226    #[must_use]
227    pub fn collapsed(mut self, collapsed: bool) -> Self {
228        self.collapsed = collapsed;
229        self
230    }
231
232    #[must_use]
233    pub fn bg(mut self, color: Color) -> Self {
234        self.bg_color = Some(color);
235        self
236    }
237
238    #[must_use]
239    pub fn fg(mut self, color: Color) -> Self {
240        self.fg_color = Some(color);
241        self
242    }
243
244    #[must_use]
245    pub fn rounded(mut self, radius: f32) -> Self {
246        self.corner_radius = Some(radius);
247        self
248    }
249
250    #[must_use]
251    pub fn text_size(mut self, size: f32) -> Self {
252        self.font_size = Some(size);
253        self
254    }
255
256    #[must_use]
257    pub fn bold(mut self) -> Self {
258        self.is_bold = true;
259        self
260    }
261}
262
263impl Widget for Panel {
264    fn draw(&self, painter: &mut dyn Painter, area: Rect) {
265        let header_height = 28.0;
266        let header = Rect::new(area.x, area.y, area.width, header_height);
267        let bg = self.bg_color.unwrap_or(Color::rgba(0.18, 0.18, 0.22, 1.0));
268        let radius = self.corner_radius.unwrap_or(3.0);
269        painter.fill_rect(header, bg, radius);
270
271        let text_color = self.fg_color.unwrap_or(Color::WHITE);
272        let style = TextStyle {
273            font_size: self.font_size.unwrap_or(13.0),
274            color: text_color,
275            ..TextStyle::default()
276        };
277
278        // Collapse indicator
279        let arrow = if self.collapsed { "▸" } else { "▾" };
280        painter.text(
281            Position::new(
282                area.x + 8.0,
283                area.y + (header_height - style.font_size) * 0.5,
284            ),
285            arrow,
286            &style,
287        );
288
289        painter.text(
290            Position::new(
291                area.x + 24.0,
292                area.y + (header_height - style.font_size) * 0.5,
293            ),
294            &self.title,
295            &style,
296        );
297
298        if !self.collapsed {
299            let content = Rect::new(
300                area.x,
301                area.y + header_height,
302                area.width,
303                area.height - header_height,
304            );
305            painter.fill_rect(content, Color::rgba(0.1, 0.1, 0.13, 1.0), 0.0);
306        }
307    }
308
309    fn ui_node(&self) -> UiNode {
310        UiNode::new("Panel", SemanticRole::Container).with_id(&self.id)
311    }
312}
313
314impl Discoverable for Panel {
315    fn schema(&self) -> WidgetSchema {
316        WidgetSchema::new(
317            "Panel",
318            "A collapsible panel container",
319            SemanticRole::Container,
320        )
321    }
322
323    fn capabilities(&self) -> Vec<AgentCapability> {
324        vec![
325            AgentCapability::Focusable,
326            AgentCapability::Expandable {
327                expanded: !self.collapsed,
328            },
329        ]
330    }
331
332    fn actions(&self) -> Vec<AgentAction> {
333        vec![
334            AgentAction::simple("toggle", "Toggle panel collapse", true),
335            AgentAction::simple("expand", "Expand the panel", true),
336            AgentAction::simple("collapse", "Collapse the panel", true),
337        ]
338    }
339
340    fn semantic_role(&self) -> SemanticRole {
341        SemanticRole::Container
342    }
343
344    fn agent_state(&self) -> serde_json::Value {
345        serde_json::json!({
346            "title": self.title,
347            "collapsed": self.collapsed,
348        })
349    }
350
351    fn execute_action(
352        &mut self,
353        action: &str,
354        _params: &serde_json::Value,
355    ) -> Result<serde_json::Value, String> {
356        match action {
357            "toggle" => {
358                self.collapsed = !self.collapsed;
359                Ok(serde_json::json!({ "collapsed": self.collapsed }))
360            }
361            "expand" => {
362                self.collapsed = false;
363                Ok(serde_json::json!({ "collapsed": false }))
364            }
365            "collapse" => {
366                self.collapsed = true;
367                Ok(serde_json::json!({ "collapsed": true }))
368            }
369            _ => Err(format!("Unknown action: {action}")),
370        }
371    }
372
373    fn agent_id(&self) -> Option<&str> {
374        Some(&self.id)
375    }
376}