1use 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#[derive(Debug, Clone)]
12pub enum MenuItem {
13 Item { id: String, label: String },
15 Separator,
17}
18
19impl MenuItem {
20 #[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 #[must_use]
31 pub fn separator() -> Self {
32 Self::Separator
33 }
34}
35
36pub 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}