use crate::core::{Color, Position, Rect, TextStyle};
use crate::ontology::{
AgentAction, AgentCapability, Discoverable, SemanticRole, UiNode, WidgetSchema,
};
use crate::paint::Painter;
use crate::widget::Widget;
#[derive(Debug, Clone)]
pub enum MenuItem {
Item { id: String, label: String },
Separator,
}
impl MenuItem {
#[must_use]
pub fn item(id: impl Into<String>, label: impl Into<String>) -> Self {
Self::Item {
id: id.into(),
label: label.into(),
}
}
#[must_use]
pub fn separator() -> Self {
Self::Separator
}
}
pub struct Menu {
pub id: String,
pub items: Vec<MenuItem>,
bg_color: Option<Color>,
fg_color: Option<Color>,
corner_radius: Option<f32>,
font_size: Option<f32>,
is_bold: bool,
}
impl Menu {
#[must_use]
pub fn new(id: impl Into<String>, items: Vec<MenuItem>) -> Self {
Self {
id: id.into(),
items,
bg_color: None,
fg_color: None,
corner_radius: None,
font_size: None,
is_bold: false,
}
}
#[must_use]
pub fn bg(mut self, color: Color) -> Self {
self.bg_color = Some(color);
self
}
#[must_use]
pub fn fg(mut self, color: Color) -> Self {
self.fg_color = Some(color);
self
}
#[must_use]
pub fn rounded(mut self, radius: f32) -> Self {
self.corner_radius = Some(radius);
self
}
#[must_use]
pub fn text_size(mut self, size: f32) -> Self {
self.font_size = Some(size);
self
}
#[must_use]
pub fn bold(mut self) -> Self {
self.is_bold = true;
self
}
}
impl Widget for Menu {
fn draw(&self, painter: &mut dyn Painter, area: Rect) {
let bg = self.bg_color.unwrap_or(Color::rgba(0.16, 0.16, 0.2, 1.0));
let radius = self.corner_radius.unwrap_or(4.0);
painter.fill_rect(area, bg, radius);
painter.stroke_rect(area, Color::rgba(0.35, 0.35, 0.45, 1.0), 1.0, radius);
let item_height = 28.0;
let sep_height = 8.0;
let padding = 12.0;
let style = TextStyle {
font_size: self.font_size.unwrap_or(13.0),
color: self.fg_color.unwrap_or(Color::WHITE),
..TextStyle::default()
};
let mut y = area.y + 4.0;
for item in &self.items {
match item {
MenuItem::Item { label, .. } => {
painter.text(
Position::new(area.x + padding, y + (item_height - style.font_size) * 0.5),
label,
&style,
);
y += item_height;
}
MenuItem::Separator => {
let sep_y = y + sep_height * 0.5;
painter.line(
Position::new(area.x + 4.0, sep_y),
Position::new(area.x + area.width - 4.0, sep_y),
Color::rgba(0.3, 0.3, 0.4, 1.0),
1.0,
);
y += sep_height;
}
}
}
}
fn ui_node(&self) -> UiNode {
UiNode::new("Menu", SemanticRole::Menu).with_id(&self.id)
}
}
impl Discoverable for Menu {
fn schema(&self) -> WidgetSchema {
WidgetSchema::new("Menu", "A context or popup menu", SemanticRole::Menu)
}
fn capabilities(&self) -> Vec<AgentCapability> {
vec![AgentCapability::Focusable]
}
fn actions(&self) -> Vec<AgentAction> {
vec![AgentAction::simple(
"select_item",
"Activate a menu item by id",
false,
)]
}
fn semantic_role(&self) -> SemanticRole {
SemanticRole::Menu
}
fn agent_state(&self) -> serde_json::Value {
let items: Vec<serde_json::Value> = self
.items
.iter()
.filter_map(|item| match item {
MenuItem::Item { id, label } => {
Some(serde_json::json!({ "id": id, "label": label }))
}
MenuItem::Separator => None,
})
.collect();
serde_json::json!({ "items": items })
}
fn execute_action(
&mut self,
action: &str,
params: &serde_json::Value,
) -> Result<serde_json::Value, String> {
match action {
"select_item" => {
if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
let found = self
.items
.iter()
.any(|item| matches!(item, MenuItem::Item { id: iid, .. } if iid == id));
if found {
Ok(serde_json::json!({ "selected": id }))
} else {
Err(format!("Unknown menu item: {id}"))
}
} else {
Err("Missing 'id' parameter".into())
}
}
_ => Err(format!("Unknown action: {action}")),
}
}
fn agent_id(&self) -> Option<&str> {
Some(&self.id)
}
}