use crate::core::buffer::Buffer;
use crate::core::rect::Rect;
use crate::core::style::Style;
use crate::core::text::Line;
use crate::ontology::{
ActionParam, ActionParamType, AgentAction, AgentCapability, Discoverable, PropertySchema,
PropertyType, SemanticRole, WidgetSchema,
};
use crate::widget::block::Block;
use crate::widget::{StatefulWidget, Widget};
#[derive(Debug, Clone)]
pub struct ListItem {
pub content: Line,
pub style: Style,
}
impl ListItem {
pub fn new(content: impl Into<Line>) -> Self {
Self {
content: content.into(),
style: Style::default(),
}
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
}
impl<T: Into<Line>> From<T> for ListItem {
fn from(content: T) -> Self {
ListItem::new(content)
}
}
#[derive(Debug, Clone, Default)]
pub struct ListState {
pub selected: Option<usize>,
pub offset: usize,
}
impl ListState {
pub fn new() -> Self {
Self::default()
}
pub fn with_selected(mut self, selected: Option<usize>) -> Self {
self.selected = selected;
self
}
pub fn select(&mut self, index: Option<usize>) {
self.selected = index;
}
pub fn select_next(&mut self, item_count: usize) {
if item_count == 0 {
return;
}
let i = match self.selected {
Some(i) => (i + 1).min(item_count - 1),
None => 0,
};
self.selected = Some(i);
}
pub fn select_previous(&mut self) {
let i = match self.selected {
Some(i) => i.saturating_sub(1),
None => 0,
};
self.selected = Some(i);
}
pub fn select_first(&mut self) {
self.selected = Some(0);
}
pub fn select_last(&mut self, item_count: usize) {
if item_count > 0 {
self.selected = Some(item_count - 1);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ListDirection {
#[default]
TopToBottom,
BottomToTop,
}
#[derive(Debug, Clone)]
pub struct List {
items: Vec<ListItem>,
block: Option<Block>,
style: Style,
highlight_style: Style,
highlight_symbol: Option<String>,
direction: ListDirection,
}
impl List {
pub fn new(items: impl IntoIterator<Item = impl Into<ListItem>>) -> Self {
Self {
items: items.into_iter().map(Into::into).collect(),
block: None,
style: Style::default(),
highlight_style: Style::default().reversed(),
highlight_symbol: None,
direction: ListDirection::TopToBottom,
}
}
pub fn block(mut self, block: Block) -> Self {
self.block = Some(block);
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn highlight_style(mut self, style: Style) -> Self {
self.highlight_style = style;
self
}
pub fn highlight_symbol(mut self, symbol: impl Into<String>) -> Self {
self.highlight_symbol = Some(symbol.into());
self
}
pub fn direction(mut self, direction: ListDirection) -> Self {
self.direction = direction;
self
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
impl StatefulWidget for List {
type State = ListState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut ListState) {
if area.is_empty() {
return;
}
buf.set_style(area, self.style);
let inner = if let Some(block) = self.block {
let inner = block.inner(area);
block.render(area, buf);
inner
} else {
area
};
if inner.is_empty() || self.items.is_empty() {
return;
}
let visible_height = inner.height as usize;
if let Some(selected) = state.selected {
if selected < state.offset {
state.offset = selected;
} else if selected >= state.offset + visible_height {
state.offset = selected - visible_height + 1;
}
}
let highlight_symbol_width = self.highlight_symbol.as_ref().map_or(0, |s| s.len() as u16);
let items_iter: Vec<(usize, &ListItem)> = match self.direction {
ListDirection::TopToBottom => self
.items
.iter()
.enumerate()
.skip(state.offset)
.take(visible_height)
.collect(),
ListDirection::BottomToTop => {
let total = self.items.len();
let end = total.saturating_sub(state.offset);
let start = end.saturating_sub(visible_height);
self.items[start..end]
.iter()
.enumerate()
.map(|(i, item)| (start + i, item))
.collect()
}
};
for (vi, (i, item)) in items_iter.iter().enumerate() {
let y = match self.direction {
ListDirection::TopToBottom => inner.y + vi as u16,
ListDirection::BottomToTop => inner.bottom().saturating_sub(1) - vi as u16,
};
let is_selected = state.selected == Some(*i);
let style = if is_selected {
self.style.patch(item.style).patch(self.highlight_style)
} else {
self.style.patch(item.style)
};
buf.set_style(Rect::new(inner.x, y, inner.width, 1), style);
let mut x = inner.x;
if is_selected {
if let Some(ref symbol) = self.highlight_symbol {
buf.set_string(x, y, symbol, style);
x += highlight_symbol_width;
}
} else if self.highlight_symbol.is_some() {
x += highlight_symbol_width;
}
let max_width = inner.right().saturating_sub(x);
buf.set_line(x, y, &item.content, max_width);
buf.set_style(Rect::new(x, y, max_width, 1), style);
}
}
}
impl Discoverable for List {
fn schema() -> WidgetSchema {
WidgetSchema {
name: "List".into(),
description:
"A scrollable list with item selection, highlight, and keyboard navigation.".into(),
default_role: SemanticRole::Selection,
properties: vec![
PropertySchema {
name: "items".into(),
description: "The list items to display.".into(),
property_type: PropertyType::Array(Box::new(PropertyType::String)),
required: true,
default_value: None,
constraints: vec![],
},
PropertySchema {
name: "highlight_symbol".into(),
description: "Symbol displayed next to the selected item.".into(),
property_type: PropertyType::String,
required: false,
default_value: Some(serde_json::json!(">> ")),
constraints: vec![],
},
],
actions: vec![],
usage_hint: Some(
"List::new([\"Item 1\", \"Item 2\"]).highlight_symbol(\">> \")".into(),
),
tags: vec![
"list".into(),
"selection".into(),
"scrollable".into(),
"menu".into(),
],
}
}
fn capabilities(&self) -> Vec<AgentCapability> {
vec![
AgentCapability::Focusable,
AgentCapability::Scrollable {
vertical: true,
horizontal: false,
},
AgentCapability::Selectable {
multi_select: false,
item_count: self.items.len(),
},
AgentCapability::HasKeyBindings {
bindings: vec![
("Up/k".into(), "Select previous item".into()),
("Down/j".into(), "Select next item".into()),
("Home/g".into(), "Select first item".into()),
("End/G".into(), "Select last item".into()),
("Enter".into(), "Confirm selection".into()),
],
},
]
}
fn actions(&self) -> Vec<AgentAction> {
vec![
AgentAction {
name: "select".into(),
description: "Select an item by index.".into(),
params: vec![ActionParam {
name: "index".into(),
description: "Zero-based item index.".into(),
param_type: ActionParamType::Index,
required: true,
default_value: None,
}],
returns: Some("The selected item text.".into()),
mutates: true,
idempotent: true,
shortcut: None,
},
AgentAction {
name: "select_next".into(),
description: "Move selection to the next item.".into(),
params: vec![],
returns: None,
mutates: true,
idempotent: false,
shortcut: Some("Down".into()),
},
AgentAction {
name: "select_previous".into(),
description: "Move selection to the previous item.".into(),
params: vec![],
returns: None,
mutates: true,
idempotent: false,
shortcut: Some("Up".into()),
},
AgentAction {
name: "get_items".into(),
description: "Get all list item texts.".into(),
params: vec![],
returns: Some("Array of item texts.".into()),
mutates: false,
idempotent: true,
shortcut: None,
},
AgentAction {
name: "get_selected".into(),
description: "Get the currently selected item index and text.".into(),
params: vec![],
returns: Some("Object with index and text, or null.".into()),
mutates: false,
idempotent: true,
shortcut: None,
},
]
}
fn semantic_role(&self) -> SemanticRole {
SemanticRole::Selection
}
fn agent_state(&self) -> serde_json::Value {
let items: Vec<String> = self
.items
.iter()
.map(|item| {
item.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect();
serde_json::json!({
"item_count": self.items.len(),
"items": items,
})
}
fn execute_action(
&mut self,
_action: &str,
_params: &serde_json::Value,
) -> Result<serde_json::Value, String> {
Err("List actions require a ListState. Use the runtime to dispatch actions.".into())
}
}