use color_eyre::eyre::Result;
use crossterm::event::{Event, KeyCode, KeyEvent};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use tears::prelude::*;
use tears::subscription::terminal::TerminalEvents;
const ACTIVITY_LIMIT: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Navigation,
Tasks,
Details,
Activity,
}
impl Focus {
const fn next(self) -> Self {
match self {
Self::Navigation => Self::Tasks,
Self::Tasks => Self::Details,
Self::Details => Self::Activity,
Self::Activity => Self::Navigation,
}
}
fn title(self, label: &'static str) -> &'static str {
match self {
Self::Navigation if matches!(label, "Navigation") => "Navigation [focused]",
Self::Tasks if matches!(label, "Tasks") => "Tasks [focused]",
Self::Details if matches!(label, "Details") => "Details [focused]",
Self::Activity if matches!(label, "Activity") => "Activity [focused]",
_ => label,
}
}
}
#[derive(Debug, Clone)]
enum Message {
FocusNext,
Navigation(NavigationMessage),
Tasks(TaskMessage),
Details(DetailMessage),
Activity(ActivityMessage),
Terminal(Event),
TerminalError(String),
Quit,
}
#[derive(Debug, Clone, Copy)]
enum NavigationMessage {
Up,
Down,
}
#[derive(Debug, Clone, Copy)]
enum TaskMessage {
Up,
Down,
Toggle,
Add,
Delete,
}
#[derive(Debug, Clone, Copy)]
enum DetailMessage {
Input(char),
Backspace,
Save,
Reset,
}
#[derive(Debug, Clone, Copy)]
enum ActivityMessage {
Clear,
}
#[derive(Debug, Clone)]
struct App {
focus: Focus,
navigation: NavigationState,
tasks: TaskListState,
details: DetailState,
activity: ActivityLogState,
status: StatusState,
}
impl Application for App {
type Message = Message;
type Flags = ();
fn new(_flags: ()) -> (Self, Command<Message>) {
let tasks = TaskListState::new();
let details = DetailState::from_task(tasks.selected_task());
(
Self {
focus: Focus::Navigation,
navigation: NavigationState::new(),
tasks,
details,
activity: ActivityLogState::new(),
status: StatusState::default(),
},
Command::none(),
)
}
fn update(&mut self, msg: Message) -> Command<Message> {
match msg {
Message::FocusNext => {
self.focus = self.focus.next();
self.status.set_info("Changed focus");
}
Message::Navigation(msg) => self.update_navigation(msg),
Message::Tasks(msg) => self.update_tasks(msg),
Message::Details(msg) => self.update_details(msg),
Message::Activity(msg) => self.update_activity(msg),
Message::Terminal(Event::Key(key)) => return handle_key_event(self.focus, key),
Message::Terminal(_) => {}
Message::TerminalError(e) => {
eprintln!("Terminal error: {e}");
return Command::effect(Action::Quit);
}
Message::Quit => return Command::effect(Action::Quit),
}
Command::none()
}
fn view(&self, frame: &mut Frame) {
let [header, body, activity, footer] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(7),
Constraint::Length(3),
])
.areas(frame.area());
let [nav_area, tasks_area, details_area] = Layout::horizontal([
Constraint::Length(24),
Constraint::Percentage(36),
Constraint::Percentage(64),
])
.areas(body);
Self::render_header(frame, header);
self.navigation
.render(frame, nav_area, self.focus == Focus::Navigation);
self.tasks
.render(frame, tasks_area, self.focus == Focus::Tasks);
self.details
.render(frame, details_area, self.focus == Focus::Details);
self.activity
.render(frame, activity, self.focus == Focus::Activity);
self.status.render(frame, footer);
}
fn subscriptions(&self) -> Vec<Subscription<Message>> {
vec![
Subscription::new(TerminalEvents::new()).map(|result| match result {
Ok(event) => Message::Terminal(event),
Err(e) => Message::TerminalError(e.to_string()),
}),
]
}
}
impl App {
fn update_navigation(&mut self, msg: NavigationMessage) {
self.navigation.update(msg);
self.status.set_info(format!(
"Selected section: {}",
self.navigation.selected_label()
));
}
fn update_tasks(&mut self, msg: TaskMessage) {
let outcome = self.tasks.update(msg);
self.details.sync_from_task(self.tasks.selected_task());
if let Some(entry) = outcome.activity {
self.activity.push(entry);
}
self.status.set_info(outcome.status);
}
fn update_details(&mut self, msg: DetailMessage) {
let outcome = self.details.update(msg);
if outcome.save_requested {
if let Some(task) = self.tasks.selected_task_mut() {
task.notes.clone_from(&self.details.notes);
self.activity
.push(format!("Updated notes for '{}'", task.title));
self.status.set_info("Saved task notes");
}
} else {
self.status.set_info(outcome.status);
}
}
fn update_activity(&mut self, msg: ActivityMessage) {
self.activity.update(msg);
self.status.set_info("Cleared activity log");
}
fn render_header(frame: &mut Frame, area: Rect) {
let text = "Dashboard state example | Tab: focus | q: quit";
let block = Block::default()
.borders(Borders::ALL)
.title("Structured State");
frame.render_widget(Paragraph::new(text).block(block), area);
}
}
#[derive(Debug, Clone)]
struct NavigationState {
items: Vec<&'static str>,
selected: usize,
}
impl NavigationState {
fn new() -> Self {
Self {
items: vec!["Inbox", "Today", "Upcoming", "Archive"],
selected: 0,
}
}
fn update(&mut self, msg: NavigationMessage) {
match msg {
NavigationMessage::Up => self.selected = self.selected.saturating_sub(1),
NavigationMessage::Down => {
self.selected = (self.selected + 1).min(self.items.len().saturating_sub(1));
}
}
}
fn selected_label(&self) -> &'static str {
self.items[self.selected]
}
fn render(&self, frame: &mut Frame, area: Rect, focused: bool) {
let items = self.items.iter().enumerate().map(|(index, label)| {
let marker = if index == self.selected { ">" } else { " " };
ListItem::new(format!("{marker} {label}"))
});
let title = if focused {
Focus::Navigation.title("Navigation")
} else {
"Navigation"
};
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title));
frame.render_widget(list, area);
}
}
#[derive(Debug, Clone)]
struct Task {
title: String,
done: bool,
notes: String,
}
#[derive(Debug, Clone)]
struct TaskListState {
tasks: Vec<Task>,
selected: usize,
next_id: usize,
}
#[derive(Debug, Clone)]
struct TaskOutcome {
status: String,
activity: Option<String>,
}
impl TaskOutcome {
fn status(status: impl Into<String>) -> Self {
Self {
status: status.into(),
activity: None,
}
}
fn activity(status: impl Into<String>, activity: impl Into<String>) -> Self {
Self {
status: status.into(),
activity: Some(activity.into()),
}
}
}
impl TaskListState {
fn new() -> Self {
Self {
tasks: vec![
Task {
title: "Review release checklist".to_string(),
done: false,
notes: "Check docs, examples, and CI status.".to_string(),
},
Task {
title: "Update onboarding guide".to_string(),
done: true,
notes: "Keep the first example small.".to_string(),
},
Task {
title: "Plan next subscription API".to_string(),
done: false,
notes: "Collect friction from examples first.".to_string(),
},
],
selected: 0,
next_id: 4,
}
}
fn update(&mut self, msg: TaskMessage) -> TaskOutcome {
match msg {
TaskMessage::Up => {
self.selected = self.selected.saturating_sub(1);
TaskOutcome::status("Selected previous task")
}
TaskMessage::Down => {
self.selected = (self.selected + 1).min(self.tasks.len().saturating_sub(1));
TaskOutcome::status("Selected next task")
}
TaskMessage::Toggle => self.toggle_selected(),
TaskMessage::Add => self.add_task(),
TaskMessage::Delete => self.delete_selected(),
}
}
fn selected_task(&self) -> Option<&Task> {
self.tasks.get(self.selected)
}
fn selected_task_mut(&mut self) -> Option<&mut Task> {
self.tasks.get_mut(self.selected)
}
fn toggle_selected(&mut self) -> TaskOutcome {
let Some(task) = self.selected_task_mut() else {
return TaskOutcome::status("No task selected");
};
task.done = !task.done;
let state = if task.done { "completed" } else { "reopened" };
TaskOutcome::activity(
format!("Marked task as {state}"),
format!("{state}: {}", task.title),
)
}
fn add_task(&mut self) -> TaskOutcome {
let title = format!("New task {}", self.next_id);
self.next_id += 1;
self.tasks.push(Task {
title: title.clone(),
done: false,
notes: "Add notes in the details panel.".to_string(),
});
self.selected = self.tasks.len() - 1;
TaskOutcome::activity("Added a task", format!("added: {title}"))
}
fn delete_selected(&mut self) -> TaskOutcome {
if self.tasks.is_empty() {
return TaskOutcome::status("No task to delete");
}
let removed = self.tasks.remove(self.selected);
self.selected = self.selected.min(self.tasks.len().saturating_sub(1));
TaskOutcome::activity(
"Deleted selected task",
format!("deleted: {}", removed.title),
)
}
fn render(&self, frame: &mut Frame, area: Rect, focused: bool) {
let items = self.tasks.iter().enumerate().map(|(index, task)| {
let marker = if index == self.selected { ">" } else { " " };
let checkbox = if task.done { "[x]" } else { "[ ]" };
ListItem::new(format!("{marker} {checkbox} {}", task.title))
});
let title = if focused {
Focus::Tasks.title("Tasks")
} else {
"Tasks"
};
let block = Block::default()
.borders(Borders::ALL)
.title(format!("{title} (up/down, space, n, d)"));
frame.render_widget(List::new(items).block(block), area);
}
}
#[derive(Debug, Clone)]
struct DetailState {
title: String,
notes: String,
}
#[derive(Debug, Clone)]
struct DetailOutcome {
status: String,
save_requested: bool,
}
impl DetailOutcome {
fn status(status: impl Into<String>) -> Self {
Self {
status: status.into(),
save_requested: false,
}
}
fn save(status: impl Into<String>) -> Self {
Self {
status: status.into(),
save_requested: true,
}
}
}
impl DetailState {
fn from_task(task: Option<&Task>) -> Self {
let Some(task) = task else {
return Self {
title: "No task selected".to_string(),
notes: String::new(),
};
};
Self {
title: task.title.clone(),
notes: task.notes.clone(),
}
}
fn sync_from_task(&mut self, task: Option<&Task>) {
*self = Self::from_task(task);
}
fn update(&mut self, msg: DetailMessage) -> DetailOutcome {
match msg {
DetailMessage::Input(c) => {
self.notes.push(c);
DetailOutcome::status("Editing notes")
}
DetailMessage::Backspace => {
self.notes.pop();
DetailOutcome::status("Editing notes")
}
DetailMessage::Save => DetailOutcome::save("Saved task notes"),
DetailMessage::Reset => DetailOutcome::status("No changes saved"),
}
}
fn render(&self, frame: &mut Frame, area: Rect, focused: bool) {
let title = if focused {
Focus::Details.title("Details")
} else {
"Details"
};
let text = format!(
"{}\n\nNotes:\n{}_\n\nType to edit, Enter to save, Esc to reload selected task.",
self.title, self.notes
);
let paragraph = Paragraph::new(text)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title(title));
frame.render_widget(paragraph, area);
}
}
#[derive(Debug, Clone)]
struct ActivityLogState {
entries: Vec<String>,
}
impl ActivityLogState {
fn new() -> Self {
Self {
entries: vec!["Application started".to_string()],
}
}
fn update(&mut self, msg: ActivityMessage) {
match msg {
ActivityMessage::Clear => self.entries.clear(),
}
}
fn push(&mut self, entry: String) {
self.entries.push(entry);
if self.entries.len() > ACTIVITY_LIMIT {
self.entries.remove(0);
}
}
fn render(&self, frame: &mut Frame, area: Rect, focused: bool) {
let title = if focused {
Focus::Activity.title("Activity")
} else {
"Activity"
};
let items = self
.entries
.iter()
.rev()
.map(|entry| ListItem::new(format!("- {entry}")))
.collect::<Vec<_>>();
let block = Block::default()
.borders(Borders::ALL)
.title(format!("{title} (c: clear)"));
frame.render_widget(List::new(items).block(block), area);
}
}
#[derive(Debug, Clone)]
struct StatusState {
message: String,
}
impl Default for StatusState {
fn default() -> Self {
Self {
message: "Ready".to_string(),
}
}
}
impl StatusState {
fn set_info(&mut self, message: impl Into<String>) {
self.message = message.into();
}
fn render(&self, frame: &mut Frame, area: Rect) {
let text = format!("Status: {}", self.message);
let block = Block::default().borders(Borders::ALL).title("Status");
frame.render_widget(Paragraph::new(text).block(block), area);
}
}
fn handle_key_event(focus: Focus, key: KeyEvent) -> Command<Message> {
match key.code {
KeyCode::Char('q') => Command::message(Message::Quit),
KeyCode::Tab => Command::message(Message::FocusNext),
KeyCode::Up => match focus {
Focus::Navigation => Command::message(Message::Navigation(NavigationMessage::Up)),
Focus::Tasks => Command::message(Message::Tasks(TaskMessage::Up)),
_ => Command::none(),
},
KeyCode::Down => match focus {
Focus::Navigation => Command::message(Message::Navigation(NavigationMessage::Down)),
Focus::Tasks => Command::message(Message::Tasks(TaskMessage::Down)),
_ => Command::none(),
},
KeyCode::Char(' ') if focus == Focus::Tasks => {
Command::message(Message::Tasks(TaskMessage::Toggle))
}
KeyCode::Char('n') if focus == Focus::Tasks => {
Command::message(Message::Tasks(TaskMessage::Add))
}
KeyCode::Char('d') if focus == Focus::Tasks => {
Command::message(Message::Tasks(TaskMessage::Delete))
}
KeyCode::Char('c') if focus == Focus::Activity => {
Command::message(Message::Activity(ActivityMessage::Clear))
}
KeyCode::Char(c) if focus == Focus::Details => {
Command::message(Message::Details(DetailMessage::Input(c)))
}
KeyCode::Backspace if focus == Focus::Details => {
Command::message(Message::Details(DetailMessage::Backspace))
}
KeyCode::Enter if focus == Focus::Details => {
Command::message(Message::Details(DetailMessage::Save))
}
KeyCode::Esc if focus == Focus::Details => {
Command::message(Message::Details(DetailMessage::Reset))
}
_ => Command::none(),
}
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let mut terminal = ratatui::init();
let runtime = Runtime::<App>::new((), 60);
let result = runtime.run(&mut terminal).await;
ratatui::restore();
result?;
Ok(())
}