use crate::core::{Color, Position, Rect, Style};
use crate::ontology::*;
use crate::runtime::Frame;
use crate::widget::StatefulWidget;
#[derive(Debug, Clone)]
pub struct PaletteCommand {
pub id: String,
pub label: String,
pub shortcut: Option<String>,
pub category: Option<String>,
}
impl PaletteCommand {
#[must_use]
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
shortcut: None,
category: None,
}
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn category(mut self, category: impl Into<String>) -> Self {
self.category = Some(category.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandPaletteState {
pub query: String,
pub open: bool,
pub selected_index: usize,
}
impl CommandPaletteState {
#[must_use]
pub fn new() -> Self {
Self {
query: String::new(),
open: false,
selected_index: 0,
}
}
}
impl Default for CommandPaletteState {
fn default() -> Self {
Self::new()
}
}
pub struct CommandPalette {
commands: Vec<PaletteCommand>,
placeholder: String,
style: Style,
agent_id: String,
}
impl CommandPalette {
#[must_use]
pub fn new(commands: Vec<PaletteCommand>) -> Self {
Self {
commands,
placeholder: "Type a command...".into(),
style: Style::default(),
agent_id: String::new(),
}
}
pub fn placeholder(mut self, ph: impl Into<String>) -> Self {
self.placeholder = ph.into();
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn bg(mut self, color: Color) -> Self {
self.style.background = Some(color);
self
}
pub fn fg(mut self, color: Color) -> Self {
self.style.foreground = Some(color);
self
}
pub fn agent_id(mut self, id: impl Into<String>) -> Self {
self.agent_id = id.into();
self
}
fn filtered_commands(&self, query: &str) -> Vec<&PaletteCommand> {
if query.is_empty() {
return self.commands.iter().collect();
}
let q = query.to_lowercase();
self.commands
.iter()
.filter(|c| {
c.label.to_lowercase().contains(&q)
|| c.id.to_lowercase().contains(&q)
|| c.category
.as_deref()
.map(|cat| cat.to_lowercase().contains(&q))
.unwrap_or(false)
})
.collect()
}
}
impl Discoverable for CommandPalette {
fn schema(&self) -> WidgetSchema {
let mut schema = WidgetSchema::new(
"CommandPalette",
"A searchable command launcher",
SemanticRole::Navigation,
);
schema.usage_hint =
Some("CommandPalette::new(commands).placeholder(\"Type a command...\")".into());
schema.tags = vec![
"command".into(),
"palette".into(),
"search".into(),
"launcher".into(),
];
schema
}
fn capabilities(&self) -> Vec<AgentCapability> {
vec![
AgentCapability::Searchable,
AgentCapability::Selectable {
multi_select: false,
item_count: 0,
},
AgentCapability::Focusable,
]
}
fn actions(&self) -> Vec<AgentAction> {
vec![
AgentAction::with_params(
"execute",
"Execute a command by ID",
vec![ActionParam::required(
"command_id",
"ID of the command to execute",
ActionParamType::String,
)],
false,
),
AgentAction::with_params(
"search",
"Search commands by query",
vec![ActionParam::required(
"query",
"Search query",
ActionParamType::String,
)],
false,
),
AgentAction::simple("open", "Open the command palette", true),
AgentAction::simple("close", "Close the command palette", true),
AgentAction::simple("list", "List all available commands", false),
]
}
fn semantic_role(&self) -> SemanticRole {
SemanticRole::Navigation
}
fn agent_state(&self) -> serde_json::Value {
let cmds: Vec<_> = self
.commands
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"label": c.label,
"shortcut": c.shortcut,
"category": c.category,
})
})
.collect();
serde_json::json!({ "commands": cmds })
}
fn execute_action(
&mut self,
action: &str,
params: &serde_json::Value,
) -> Result<serde_json::Value, String> {
match action {
"execute" => {
let cmd_id = params
.get("command_id")
.and_then(|v| v.as_str())
.ok_or("Missing command_id")?;
match self.commands.iter().find(|c| c.id == cmd_id) {
Some(cmd) => Ok(serde_json::json!({
"executed": cmd.id,
"label": cmd.label,
})),
None => Err(format!("Unknown command: {cmd_id}")),
}
}
"search" => {
let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
let results: Vec<_> = self
.filtered_commands(query)
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"label": c.label,
})
})
.collect();
Ok(serde_json::json!({ "results": results }))
}
"list" => Ok(self.agent_state()),
"open" | "close" => Ok(serde_json::json!({ "status": action })),
_ => Err(format!("Unknown action: {action}")),
}
}
fn agent_id(&self) -> Option<&str> {
if self.agent_id.is_empty() {
None
} else {
Some(&self.agent_id)
}
}
fn accessibility_label(&self) -> Option<String> {
if self.placeholder.is_empty() {
None
} else {
Some(self.placeholder.clone())
}
}
}
impl StatefulWidget for CommandPalette {
type State = CommandPaletteState;
fn render(self, area: Rect, frame: &mut Frame<'_>, state: &mut CommandPaletteState) {
if !state.open {
return;
}
let filtered = self.filtered_commands(&state.query);
if state.selected_index >= filtered.len() {
state.selected_index = 0;
}
if !self.agent_id.is_empty() {
let results: Vec<_> = filtered
.iter()
.map(|c| serde_json::json!({"id": c.id, "label": c.label}))
.collect();
let node = UiNode::new("CommandPalette", SemanticRole::Navigation)
.with_id(&self.agent_id)
.with_bounds(area.into())
.with_property("query", serde_json::json!(state.query))
.with_property("open", serde_json::json!(state.open))
.with_property("selected_index", serde_json::json!(state.selected_index))
.with_property("filtered_results", serde_json::json!(results));
frame.register_widget(node);
frame.register_hitbox(&self.agent_id, area, 10);
}
let pw = area.width * 0.7;
let ph = area.height * 0.5;
let px = area.x + area.width * 0.15;
let py = area.y + 40.0;
let palette_rect = Rect::new(px, py, pw, ph);
let palette_bg = self.style.background.unwrap_or(Color::DARK_GRAY);
frame.painter().fill_rect(palette_rect, palette_bg, 8.0);
frame
.painter()
.stroke_rect(palette_rect, Color::GRAY, 1.0, 8.0);
let mut title_ts = self.style.resolved_text();
if title_ts.font_size == 14.0 {
title_ts.font_size = 16.0;
}
title_ts.weight = crate::core::style::FontWeight::Bold;
frame.painter().text(
Position::new(px + 8.0, py + 8.0),
"Command Palette",
&title_ts,
);
let query_ts = self.style.resolved_text();
let query_display = if state.query.is_empty() {
"Type to search..."
} else {
&state.query
};
frame
.painter()
.text(Position::new(px + 8.0, py + 32.0), query_display, &query_ts);
frame.painter().line(
Position::new(px + 4.0, py + 52.0),
Position::new(px + pw - 4.0, py + 52.0),
Color::GRAY,
1.0,
);
frame
.painter()
.push_clip(Rect::new(px, py + 56.0, pw, ph - 60.0));
let item_ts = self.style.resolved_text();
for (i, cmd) in filtered.iter().enumerate() {
let iy = py + 56.0 + i as f32 * 24.0;
if i == state.selected_index {
frame.painter().fill_rect(
Rect::new(px + 2.0, iy, pw - 4.0, 22.0),
Color::from_rgba8(60, 60, 120, 255),
4.0,
);
}
let text = if let Some(ref sc) = cmd.shortcut {
format!("{} ({})", cmd.label, sc)
} else {
cmd.label.clone()
};
frame
.painter()
.text(Position::new(px + 12.0, iy + 3.0), &text, &item_ts);
}
frame.painter().pop_clip();
}
}