use crate::core::buffer::Buffer;
use crate::core::rect::Rect;
use crate::interaction::{
HitRegion, InteractionLayer, ScrollRowHit, SelectableSpan, SelectionGroup, TextRange,
WidgetAction, WidgetId, WidgetRole, WidgetState,
};
use crate::sanitize;
use crate::theme::ThemeTokens;
use crate::widgets::Widget;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TodoStatus {
Open,
Active,
Done,
Blocked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TodoListStyle {
Compact,
Spacious,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TodoItem {
pub id: String,
pub text: String,
pub status: TodoStatus,
pub note: Option<String>,
}
impl TodoItem {
pub fn new(id: &str, text: &str, status: TodoStatus) -> Self {
Self {
id: id.to_string(),
text: text.to_string(),
status,
note: None,
}
}
pub fn with_note(mut self, note: &str) -> Self {
self.note = Some(note.to_string());
self
}
}
#[derive(Debug, Clone)]
pub struct TodoList<'a> {
pub items: &'a [TodoItem],
pub style: TodoListStyle,
pub hover: Option<usize>,
pub selected: Option<usize>,
pub selectable: bool,
pub active_marker: String,
pub done_marker: String,
pub open_marker: String,
pub blocked_marker: String,
pub tokens: ThemeTokens,
pub region_id: Option<WidgetId>,
}
impl<'a> TodoList<'a> {
pub fn new(items: &'a [TodoItem]) -> Self {
Self {
items,
style: TodoListStyle::Compact,
hover: None,
selected: None,
selectable: true,
active_marker: "[*]".to_string(),
done_marker: "[x]".to_string(),
open_marker: "[ ]".to_string(),
blocked_marker: "[!]".to_string(),
tokens: ThemeTokens::SCRIN,
region_id: None,
}
}
pub fn with_style(mut self, style: TodoListStyle) -> Self {
self.style = style;
self
}
pub fn with_hover(mut self, row: Option<usize>) -> Self {
self.hover = row;
self
}
pub fn with_selected(mut self, row: Option<usize>) -> Self {
self.selected = row;
self
}
pub fn with_selectable(mut self, selectable: bool) -> Self {
self.selectable = selectable;
self
}
pub fn with_active_marker(mut self, marker: &str) -> Self {
self.active_marker = marker.to_string();
self
}
pub fn with_done_marker(mut self, marker: &str) -> Self {
self.done_marker = marker.to_string();
self
}
pub fn with_tokens(mut self, tokens: ThemeTokens) -> Self {
self.tokens = tokens;
self
}
pub fn with_region_id(mut self, id: impl Into<WidgetId>) -> Self {
self.region_id = Some(id.into());
self
}
pub fn render_with_interaction(
&self,
buffer: &mut Buffer,
area: Rect,
layer: &mut InteractionLayer,
) {
self.render(buffer, area);
if area.is_empty() {
return;
}
let region_id = self
.region_id
.clone()
.unwrap_or_else(|| WidgetId::new("todo-list"));
layer.push_region(
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::Panel)
.with_label("todo list"),
);
let group = SelectionGroup::new(format!("{}:items", region_id.as_ref()));
let mut row_hits = Vec::new();
for (row, item) in self.items.iter().take(area.height as usize).enumerate() {
let y = area.y as usize + row;
let row_id = WidgetId::new(format!("{}:{}", region_id.as_ref(), item.id));
let row_area = Rect::new(area.x, y as u16, area.width, 1);
let selected = self.selected == Some(row);
let hovered = self.hover == Some(row);
layer.push_region(
HitRegion::new(row_id.clone(), row_area)
.with_role(WidgetRole::TodoItem)
.with_label(item.text.clone())
.with_action(WidgetAction::Toggle)
.with_row(row)
.with_selection_group(group.clone())
.with_state(
WidgetState::default()
.selected(selected)
.hovered(hovered)
.checked(item.status == TodoStatus::Done)
.active(item.status == TodoStatus::Active),
)
.with_z_index(1),
);
let span_id = format!("{}:span", row_id.as_ref());
row_hits.push(
ScrollRowHit::new(row_id.clone(), row)
.with_span_id(span_id.clone())
.with_item_id(row_id),
);
if self.selectable {
let display = sanitize::sanitize_str(&row_text(self, item), area.width as usize);
layer.push_selectable_span(
SelectableSpan::new(
span_id,
display.clone(),
0..display.len(),
Rect::new(
area.x,
y as u16,
sanitize::str_display_width(&display) as u16,
1,
),
)
.with_source_id(region_id.clone())
.with_group(group.clone())
.with_logical_range(TextRange::new(
row,
0,
sanitize::str_display_width(&display),
)),
);
}
}
layer.push_scroll_region(region_id, area, 0, row_hits);
}
}
impl Widget for TodoList<'_> {
fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
for (row, item) in self.items.iter().take(area.height as usize).enumerate() {
let y = area.y as usize + row;
let bg = if self.selected == Some(row) {
Some(self.tokens.accent.dim(0.65))
} else if self.hover == Some(row) {
Some(self.tokens.panel.brighten(0.08))
} else {
None
};
for x in area.x as usize..area.right() as usize {
buffer.set(
x,
y,
crate::core::buffer::Cell::new(' ', self.tokens.text, bg),
);
}
let color = match item.status {
TodoStatus::Open => self.tokens.dim,
TodoStatus::Active => self.tokens.warning,
TodoStatus::Done => self.tokens.success,
TodoStatus::Blocked => self.tokens.error,
};
buffer.set_str(
area.x as usize,
y,
&sanitize::truncate_str(&row_text(self, item), area.width as usize),
color,
bg,
);
}
}
}
fn row_text(list: &TodoList<'_>, item: &TodoItem) -> String {
let marker = match item.status {
TodoStatus::Open => &list.open_marker,
TodoStatus::Active => &list.active_marker,
TodoStatus::Done => &list.done_marker,
TodoStatus::Blocked => &list.blocked_marker,
};
match &item.note {
Some(note) => format!("{} {} - {}", marker, item.text, note),
None => format!("{} {}", marker, item.text),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn todo_list_registers_clickable_rows() {
let items = [TodoItem::new("a", "Ship", TodoStatus::Active)];
let list = TodoList::new(&items).with_region_id("todos");
let mut buffer = Buffer::new(24, 2);
let mut layer = InteractionLayer::new();
list.render_with_interaction(&mut buffer, Rect::new(0, 0, 24, 2), &mut layer);
let hit = layer.hit_test(1, 0).unwrap();
assert_eq!(hit.id.as_ref(), "todos:a");
assert_eq!(hit.role, WidgetRole::TodoItem);
assert!(hit.state.active);
}
}