Skip to main content

agpu/widget/
menu.rs

1//! Menu widget with menu items, separators, and sub-menus.
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 single item in a menu.
11#[derive(Debug, Clone)]
12pub enum MenuItem {
13    /// A clickable item with an id and display label.
14    Item { id: String, label: String },
15    /// A visual separator line.
16    Separator,
17}
18
19impl MenuItem {
20    /// Create a clickable menu item.
21    #[must_use]
22    pub fn item(id: impl Into<String>, label: impl Into<String>) -> Self {
23        Self::Item {
24            id: id.into(),
25            label: label.into(),
26        }
27    }
28
29    /// Create a separator.
30    #[must_use]
31    pub fn separator() -> Self {
32        Self::Separator
33    }
34}
35
36/// A vertical context/popup menu.
37pub struct Menu {
38    pub id: String,
39    pub items: Vec<MenuItem>,
40    bg_color: Option<Color>,
41    fg_color: Option<Color>,
42    corner_radius: Option<f32>,
43    font_size: Option<f32>,
44    is_bold: bool,
45}
46
47impl Menu {
48    #[must_use]
49    pub fn new(id: impl Into<String>, items: Vec<MenuItem>) -> Self {
50        Self {
51            id: id.into(),
52            items,
53            bg_color: None,
54            fg_color: None,
55            corner_radius: None,
56            font_size: None,
57            is_bold: false,
58        }
59    }
60
61    #[must_use]
62    pub fn bg(mut self, color: Color) -> Self {
63        self.bg_color = Some(color);
64        self
65    }
66
67    #[must_use]
68    pub fn fg(mut self, color: Color) -> Self {
69        self.fg_color = Some(color);
70        self
71    }
72
73    #[must_use]
74    pub fn rounded(mut self, radius: f32) -> Self {
75        self.corner_radius = Some(radius);
76        self
77    }
78
79    #[must_use]
80    pub fn text_size(mut self, size: f32) -> Self {
81        self.font_size = Some(size);
82        self
83    }
84
85    #[must_use]
86    pub fn bold(mut self) -> Self {
87        self.is_bold = true;
88        self
89    }
90}
91
92impl Widget for Menu {
93    fn draw(&self, painter: &mut dyn Painter, area: Rect) {
94        let bg = self.bg_color.unwrap_or(Color::rgba(0.16, 0.16, 0.2, 1.0));
95        let radius = self.corner_radius.unwrap_or(4.0);
96        painter.fill_rect(area, bg, radius);
97        painter.stroke_rect(area, Color::rgba(0.35, 0.35, 0.45, 1.0), 1.0, radius);
98
99        let item_height = 28.0;
100        let sep_height = 8.0;
101        let padding = 12.0;
102
103        let style = TextStyle {
104            font_size: self.font_size.unwrap_or(13.0),
105            color: self.fg_color.unwrap_or(Color::WHITE),
106            ..TextStyle::default()
107        };
108
109        let mut y = area.y + 4.0;
110        for item in &self.items {
111            match item {
112                MenuItem::Item { label, .. } => {
113                    painter.text(
114                        Position::new(area.x + padding, y + (item_height - style.font_size) * 0.5),
115                        label,
116                        &style,
117                    );
118                    y += item_height;
119                }
120                MenuItem::Separator => {
121                    let sep_y = y + sep_height * 0.5;
122                    painter.line(
123                        Position::new(area.x + 4.0, sep_y),
124                        Position::new(area.x + area.width - 4.0, sep_y),
125                        Color::rgba(0.3, 0.3, 0.4, 1.0),
126                        1.0,
127                    );
128                    y += sep_height;
129                }
130            }
131        }
132    }
133
134    fn ui_node(&self) -> UiNode {
135        UiNode::new("Menu", SemanticRole::Menu).with_id(&self.id)
136    }
137}
138
139impl Discoverable for Menu {
140    fn schema(&self) -> WidgetSchema {
141        WidgetSchema::new("Menu", "A context or popup menu", SemanticRole::Menu)
142    }
143
144    fn capabilities(&self) -> Vec<AgentCapability> {
145        vec![AgentCapability::Focusable]
146    }
147
148    fn actions(&self) -> Vec<AgentAction> {
149        vec![AgentAction::simple(
150            "select_item",
151            "Activate a menu item by id",
152            false,
153        )]
154    }
155
156    fn semantic_role(&self) -> SemanticRole {
157        SemanticRole::Menu
158    }
159
160    fn agent_state(&self) -> serde_json::Value {
161        let items: Vec<serde_json::Value> = self
162            .items
163            .iter()
164            .filter_map(|item| match item {
165                MenuItem::Item { id, label } => {
166                    Some(serde_json::json!({ "id": id, "label": label }))
167                }
168                MenuItem::Separator => None,
169            })
170            .collect();
171        serde_json::json!({ "items": items })
172    }
173
174    fn execute_action(
175        &mut self,
176        action: &str,
177        params: &serde_json::Value,
178    ) -> Result<serde_json::Value, String> {
179        match action {
180            "select_item" => {
181                if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
182                    let found = self
183                        .items
184                        .iter()
185                        .any(|item| matches!(item, MenuItem::Item { id: iid, .. } if iid == id));
186                    if found {
187                        Ok(serde_json::json!({ "selected": id }))
188                    } else {
189                        Err(format!("Unknown menu item: {id}"))
190                    }
191                } else {
192                    Err("Missing 'id' parameter".into())
193                }
194            }
195            _ => Err(format!("Unknown action: {action}")),
196        }
197    }
198
199    fn agent_id(&self) -> Option<&str> {
200        Some(&self.id)
201    }
202}