Skip to main content

agpu/widget/
list.rs

1//! List and Table widgets with virtualized scrolling support.
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 scrollable list of items.
11pub struct List {
12    pub id: String,
13    pub items: Vec<String>,
14    pub selected: Option<usize>,
15    pub scroll_offset: f32,
16    pub item_height: f32,
17    bg_color: Option<Color>,
18    fg_color: Option<Color>,
19    corner_radius: Option<f32>,
20    font_size: Option<f32>,
21    is_bold: bool,
22}
23
24impl List {
25    #[must_use]
26    pub fn new(id: impl Into<String>, items: Vec<String>) -> Self {
27        Self {
28            id: id.into(),
29            items,
30            selected: None,
31            scroll_offset: 0.0,
32            item_height: 28.0,
33            bg_color: None,
34            fg_color: None,
35            corner_radius: None,
36            font_size: None,
37            is_bold: false,
38        }
39    }
40
41    #[must_use]
42    pub fn selected(mut self, index: usize) -> Self {
43        self.selected = Some(index);
44        self
45    }
46
47    #[must_use]
48    pub fn bg(mut self, color: Color) -> Self {
49        self.bg_color = Some(color);
50        self
51    }
52
53    #[must_use]
54    pub fn fg(mut self, color: Color) -> Self {
55        self.fg_color = Some(color);
56        self
57    }
58
59    #[must_use]
60    pub fn rounded(mut self, radius: f32) -> Self {
61        self.corner_radius = Some(radius);
62        self
63    }
64
65    #[must_use]
66    pub fn text_size(mut self, size: f32) -> Self {
67        self.font_size = Some(size);
68        self
69    }
70
71    #[must_use]
72    pub fn bold(mut self) -> Self {
73        self.is_bold = true;
74        self
75    }
76}
77
78impl Widget for List {
79    fn draw(&self, painter: &mut dyn Painter, area: Rect) {
80        let bg = self.bg_color.unwrap_or(Color::rgba(0.12, 0.12, 0.15, 1.0));
81        let radius = self.corner_radius.unwrap_or(3.0);
82        painter.fill_rect(area, bg, radius);
83
84        let text_color = self.fg_color.unwrap_or(Color::WHITE);
85        let style = TextStyle {
86            font_size: self.font_size.unwrap_or(14.0),
87            color: text_color,
88            ..TextStyle::default()
89        };
90
91        let padding = 6.0;
92        let visible_start = (self.scroll_offset / self.item_height).floor() as usize;
93        let visible_count = (area.height / self.item_height).ceil() as usize + 1;
94
95        for i in visible_start..self.items.len().min(visible_start + visible_count) {
96            let y = area.y + i as f32 * self.item_height - self.scroll_offset;
97            if y + self.item_height < area.y || y > area.y + area.height {
98                continue;
99            }
100
101            let item_rect = Rect::new(area.x, y, area.width, self.item_height);
102
103            if self.selected == Some(i) {
104                painter.fill_rect(item_rect, Color::rgba(0.2, 0.4, 0.7, 0.5), 0.0);
105            }
106
107            let text_color = if self.selected == Some(i) {
108                Color::WHITE
109            } else {
110                style.color
111            };
112
113            let text_style = TextStyle {
114                color: text_color,
115                ..style.clone()
116            };
117
118            painter.text(
119                Position::new(
120                    area.x + padding,
121                    y + (self.item_height - style.font_size) * 0.5,
122                ),
123                &self.items[i],
124                &text_style,
125            );
126        }
127    }
128
129    fn ui_node(&self) -> UiNode {
130        UiNode::new("List", SemanticRole::Selection).with_id(&self.id)
131    }
132}
133
134impl Discoverable for List {
135    fn schema(&self) -> WidgetSchema {
136        WidgetSchema::new(
137            "List",
138            "A scrollable list of items",
139            SemanticRole::Selection,
140        )
141    }
142
143    fn capabilities(&self) -> Vec<AgentCapability> {
144        vec![
145            AgentCapability::Focusable,
146            AgentCapability::Scrollable {
147                vertical: true,
148                horizontal: false,
149            },
150            AgentCapability::Selectable {
151                multi_select: false,
152                item_count: self.items.len(),
153            },
154        ]
155    }
156
157    fn actions(&self) -> Vec<AgentAction> {
158        vec![
159            AgentAction::simple("select", "Select an item by index", true),
160            AgentAction::simple("scroll", "Scroll the list", true),
161        ]
162    }
163
164    fn semantic_role(&self) -> SemanticRole {
165        SemanticRole::Selection
166    }
167
168    fn agent_state(&self) -> serde_json::Value {
169        serde_json::json!({
170            "item_count": self.items.len(),
171            "selected": self.selected,
172        })
173    }
174
175    fn execute_action(
176        &mut self,
177        action: &str,
178        params: &serde_json::Value,
179    ) -> Result<serde_json::Value, String> {
180        match action {
181            "select" => {
182                if let Some(idx) = params.get("index").and_then(|v| v.as_u64()) {
183                    let idx = idx as usize;
184                    if idx < self.items.len() {
185                        self.selected = Some(idx);
186                        Ok(serde_json::json!({ "selected": idx }))
187                    } else {
188                        Err("Index out of range".into())
189                    }
190                } else {
191                    Err("Missing 'index' parameter".into())
192                }
193            }
194            _ => Err(format!("Unknown action: {action}")),
195        }
196    }
197
198    fn agent_id(&self) -> Option<&str> {
199        Some(&self.id)
200    }
201}
202
203/// A data table with column headers and rows.
204pub struct Table {
205    pub id: String,
206    pub columns: Vec<String>,
207    pub rows: Vec<Vec<String>>,
208    pub selected_row: Option<usize>,
209    pub scroll_offset: f32,
210    pub row_height: f32,
211    pub header_height: f32,
212    bg_color: Option<Color>,
213    fg_color: Option<Color>,
214    corner_radius: Option<f32>,
215    font_size: Option<f32>,
216    is_bold: bool,
217}
218
219impl Table {
220    #[must_use]
221    pub fn new(id: impl Into<String>, columns: Vec<String>, rows: Vec<Vec<String>>) -> Self {
222        Self {
223            id: id.into(),
224            columns,
225            rows,
226            selected_row: None,
227            scroll_offset: 0.0,
228            row_height: 28.0,
229            header_height: 32.0,
230            bg_color: None,
231            fg_color: None,
232            corner_radius: None,
233            font_size: None,
234            is_bold: false,
235        }
236    }
237
238    #[must_use]
239    pub fn bg(mut self, color: Color) -> Self {
240        self.bg_color = Some(color);
241        self
242    }
243
244    #[must_use]
245    pub fn fg(mut self, color: Color) -> Self {
246        self.fg_color = Some(color);
247        self
248    }
249
250    #[must_use]
251    pub fn rounded(mut self, radius: f32) -> Self {
252        self.corner_radius = Some(radius);
253        self
254    }
255
256    #[must_use]
257    pub fn text_size(mut self, size: f32) -> Self {
258        self.font_size = Some(size);
259        self
260    }
261
262    #[must_use]
263    pub fn bold(mut self) -> Self {
264        self.is_bold = true;
265        self
266    }
267}
268
269impl Widget for Table {
270    fn draw(&self, painter: &mut dyn Painter, area: Rect) {
271        let bg = self.bg_color.unwrap_or(Color::rgba(0.1, 0.1, 0.13, 1.0));
272        let radius = self.corner_radius.unwrap_or(3.0);
273        painter.fill_rect(area, bg, radius);
274
275        let col_count = self.columns.len().max(1);
276        let col_width = area.width / col_count as f32;
277        let fs = self.font_size.unwrap_or(13.0);
278
279        let header_style = TextStyle {
280            font_size: fs,
281            color: self.fg_color.unwrap_or(Color::rgba(0.7, 0.7, 0.8, 1.0)),
282            ..TextStyle::default()
283        };
284
285        // Header
286        let header_rect = Rect::new(area.x, area.y, area.width, self.header_height);
287        painter.fill_rect(header_rect, Color::rgba(0.15, 0.15, 0.2, 1.0), 0.0);
288
289        for (i, col) in self.columns.iter().enumerate() {
290            painter.text(
291                Position::new(
292                    area.x + i as f32 * col_width + 6.0,
293                    area.y + (self.header_height - header_style.font_size) * 0.5,
294                ),
295                col,
296                &header_style,
297            );
298        }
299
300        // Rows
301        let row_style = TextStyle {
302            font_size: fs,
303            color: self.fg_color.unwrap_or(Color::WHITE),
304            ..TextStyle::default()
305        };
306
307        let body_y = area.y + self.header_height;
308        for (ri, row) in self.rows.iter().enumerate() {
309            let y = body_y + ri as f32 * self.row_height - self.scroll_offset;
310            if y + self.row_height < body_y || y > area.y + area.height {
311                continue;
312            }
313
314            if self.selected_row == Some(ri) {
315                let row_rect = Rect::new(area.x, y, area.width, self.row_height);
316                painter.fill_rect(row_rect, Color::rgba(0.2, 0.4, 0.7, 0.4), 0.0);
317            }
318
319            for (ci, cell) in row.iter().enumerate().take(col_count) {
320                painter.text(
321                    Position::new(
322                        area.x + ci as f32 * col_width + 6.0,
323                        y + (self.row_height - row_style.font_size) * 0.5,
324                    ),
325                    cell,
326                    &row_style,
327                );
328            }
329        }
330    }
331
332    fn ui_node(&self) -> UiNode {
333        UiNode::new("Table", SemanticRole::DataVisualization).with_id(&self.id)
334    }
335}
336
337impl Discoverable for Table {
338    fn schema(&self) -> WidgetSchema {
339        WidgetSchema::new(
340            "Table",
341            "A data table with headers and rows",
342            SemanticRole::DataVisualization,
343        )
344    }
345
346    fn capabilities(&self) -> Vec<AgentCapability> {
347        vec![
348            AgentCapability::Focusable,
349            AgentCapability::Scrollable {
350                vertical: true,
351                horizontal: false,
352            },
353            AgentCapability::Selectable {
354                multi_select: false,
355                item_count: self.rows.len(),
356            },
357            AgentCapability::Sortable {
358                columns: self.columns.clone(),
359            },
360        ]
361    }
362
363    fn actions(&self) -> Vec<AgentAction> {
364        vec![
365            AgentAction::simple("select_row", "Select a table row", true),
366            AgentAction::simple("scroll", "Scroll the table", true),
367            AgentAction::simple("sort", "Sort by column", false),
368        ]
369    }
370
371    fn semantic_role(&self) -> SemanticRole {
372        SemanticRole::DataVisualization
373    }
374
375    fn agent_state(&self) -> serde_json::Value {
376        serde_json::json!({
377            "columns": self.columns,
378            "row_count": self.rows.len(),
379            "selected_row": self.selected_row,
380        })
381    }
382
383    fn execute_action(
384        &mut self,
385        action: &str,
386        params: &serde_json::Value,
387    ) -> Result<serde_json::Value, String> {
388        match action {
389            "select_row" => {
390                if let Some(idx) = params.get("index").and_then(|v| v.as_u64()) {
391                    let idx = idx as usize;
392                    if idx < self.rows.len() {
393                        self.selected_row = Some(idx);
394                        Ok(serde_json::json!({ "selected_row": idx }))
395                    } else {
396                        Err("Index out of range".into())
397                    }
398                } else {
399                    Err("Missing 'index' parameter".into())
400                }
401            }
402            _ => Err(format!("Unknown action: {action}")),
403        }
404    }
405
406    fn agent_id(&self) -> Option<&str> {
407        Some(&self.id)
408    }
409}