feat: integrar painel de listas no painel de tarefas

- Remove Focus::Tabs, tabs das listas passam para dentro do painel esquerdo
- Layout simplificado: top area eliminada, body/calendar/status apenas
- Navegação entre listas com ←/→ no foco TaskList
- Tabs dinâmicas: separador '|', alinhamento à esquerda,
  número variável conforme largura do terminal
- Indicadores ' < ' e '>>' para overflow horizontal
- Scroll horizontal (list_tabs_scroll) com clamping automático
This commit is contained in:
2026-06-26 14:08:51 +01:00
parent 5cfad78ef8
commit 0c842c0e14
4 changed files with 144 additions and 124 deletions
+18 -47
View File
@@ -26,6 +26,7 @@ pub struct App {
pub tasks: Vec<Task>,
pub calendar_events: Vec<CalendarEvent>,
pub selected_list: usize,
pub list_tabs_scroll: usize,
pub selected_task: usize,
pub focus: Focus,
pub show_popup: Option<Popup>,
@@ -103,8 +104,9 @@ impl App {
tasks,
calendar_events: Vec::new(),
selected_list: 0,
list_tabs_scroll: 0,
selected_task: 0,
focus: Focus::Tabs,
focus: Focus::TaskList,
show_popup,
network_status: NetworkStatus::Online,
popup_input: String::new(),
@@ -335,15 +337,14 @@ impl App {
self.calendar_active_week += 1;
} else {
self.calendar_active_week = 0;
self.focus = Focus::Tabs;
self.focus = Focus::TaskList;
}
}
_ => {
self.focus = match self.focus {
Focus::Tabs => Focus::TaskList,
Focus::TaskList => Focus::Detail,
Focus::Detail => Focus::Calendar,
_ => Focus::Tabs,
Focus::Calendar => Focus::TaskList,
};
}
}
@@ -388,7 +389,6 @@ impl App {
Focus::Calendar => {
self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_sub(1);
}
_ => {}
},
KeyCode::Down => match self.focus {
Focus::TaskList => {
@@ -404,13 +404,15 @@ impl App {
Focus::Calendar => {
self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_add(1);
}
_ => {}
},
KeyCode::Right => {
match self.focus {
Focus::Tabs => {
Focus::TaskList => {
if !self.lists.is_empty() && self.selected_list + 1 < self.lists.len() {
self.selected_list += 1;
if self.selected_list >= self.list_tabs_scroll + 4 {
self.list_tabs_scroll += 1;
}
self.load_tasks();
}
}
@@ -424,9 +426,12 @@ impl App {
}
KeyCode::Left => {
match self.focus {
Focus::Tabs => {
Focus::TaskList => {
if !self.lists.is_empty() && self.selected_list > 0 {
self.selected_list -= 1;
if self.selected_list < self.list_tabs_scroll {
self.list_tabs_scroll = self.list_tabs_scroll.saturating_sub(1);
}
self.load_tasks();
}
}
@@ -445,23 +450,12 @@ impl App {
}
KeyCode::Char('d') | KeyCode::Char('D') => {
if !self.needs_auth {
let context = match self.focus {
Focus::Tabs => {
if self.selected_list < self.lists.len() {
format!("Delete list: \"{}\"?", self.lists[self.selected_list].title)
} else {
"Delete this list?".to_string()
}
}
_ => {
if !self.tasks.is_empty() && self.selected_task < self.tasks.len() {
let context = if !self.tasks.is_empty() && self.selected_task < self.tasks.len() {
let title = &self.tasks[self.selected_task].title;
let preview: String = title.chars().take(40).collect();
format!("Delete task: \"{}\"?", preview)
} else {
"Delete this task?".to_string()
}
}
};
self.show_popup = Some(Popup::ConfirmDelete { context });
}
@@ -851,31 +845,6 @@ impl App {
self.show_popup = None;
}
KeyCode::Enter => {
match self.focus {
Focus::Tabs => {
if self.selected_list < self.lists.len() {
let list_id = self.lists[self.selected_list].id.clone();
let title = self.lists[self.selected_list].title.clone();
self.db.delete_list(&list_id).ok();
self.db.push_sync(
SyncAction::DeleteList,
&list_id,
&list_id,
"",
).ok();
crate::log_msg(&format!(
"[task_app] LIST DELETE: title=\"{}\" id={}",
title, list_id
));
self.trigger_sync();
self.load_lists();
if self.selected_list >= self.lists.len() {
self.selected_list = self.lists.len().saturating_sub(1);
}
self.load_tasks();
}
}
Focus::TaskList | Focus::Detail | Focus::Calendar => {
if !self.tasks.is_empty() && self.selected_task < self.tasks.len() {
let task = &self.tasks[self.selected_task];
let task_id = task.id.clone();
@@ -900,8 +869,6 @@ impl App {
self.selected_task = self.tasks.len().saturating_sub(1);
}
}
}
}
self.show_popup = None;
}
_ => {}
@@ -1258,6 +1225,10 @@ impl App {
fn load_lists(&mut self) {
self.lists = self.db.get_lists();
let max_scroll = self.lists.len().saturating_sub(4);
if self.list_tabs_scroll > max_scroll {
self.list_tabs_scroll = max_scroll;
}
}
fn load_tasks(&mut self) {
+1
View File
@@ -160,6 +160,7 @@ fn main() -> io::Result<()> {
tasks: &app.tasks,
calendar_events: &app.calendar_events,
selected_list: app.selected_list,
list_tabs_scroll: app.list_tabs_scroll,
selected_task: app.selected_task,
focus: app.focus.clone(),
show_popup: app.show_popup.as_ref(),
+92 -42
View File
@@ -4,7 +4,7 @@ use chrono::Datelike;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs, Wrap};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::Frame;
@@ -12,7 +12,6 @@ use crate::domain::models::*;
use crate::app::SyncStats;
use super::NetworkStatus;
const TAB_COLOR: Color = Color::Cyan;
const FOCUS_COLOR: Color = Color::Yellow;
const SELECTED_COLOR: Color = Color::Green;
const DETAIL_COLOR: Color = Color::White;
@@ -22,37 +21,6 @@ const STATUS_SYNC: Color = Color::Yellow;
const POPUP_BG: Color = Color::Black;
const POPUP_BORDER: Color = Color::Cyan;
pub fn render_tabs_bar(
frame: &mut Frame,
area: Rect,
lists: &[TaskList],
selected: usize,
focused: bool,
) {
let tab_titles: Vec<&str> = lists.iter().map(|l| l.title.as_str()).collect();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { TAB_COLOR }))
.title(" Lists ")
.title_alignment(Alignment::Left);
let tabs = if tab_titles.is_empty() {
Tabs::new(vec![Line::from(" No lists ")])
.block(block)
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.select(selected)
} else {
Tabs::new(tab_titles.into_iter().map(|t| Line::from(Span::raw(t))).collect::<Vec<_>>())
.block(block)
.divider(Span::raw(" | "))
.highlight_style(Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD))
.select(selected)
};
frame.render_widget(tabs, area);
}
fn relative_due_str(due: chrono::NaiveDateTime) -> (String, Color) {
let now = chrono::Local::now().naive_local();
let diff = due - now;
@@ -74,6 +42,9 @@ fn relative_due_str(due: chrono::NaiveDateTime) -> (String, Color) {
pub fn render_task_list(
frame: &mut Frame,
area: Rect,
lists: &[TaskList],
selected_list: usize,
list_tabs_scroll: usize,
tasks: &[Task],
selected: usize,
focused: bool,
@@ -84,7 +55,26 @@ pub fn render_task_list(
let done = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count();
let todo = total - done;
let content_width = (area.width as usize).saturating_sub(5);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
.title(format!(" Tasks ({} todo / {} done) ", todo, done))
.title_alignment(Alignment::Left);
let inner = block.inner(area);
frame.render_widget(&block, area);
let inner_split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(inner);
let tabs_area = inner_split[0];
let tasks_area = inner_split[1];
render_list_tabs(frame, tabs_area, lists, selected_list, list_tabs_scroll, focused);
let content_width = (tasks_area.width as usize).saturating_sub(1);
let items: Vec<ListItem> = tasks
.iter()
@@ -160,14 +150,8 @@ pub fn render_task_list(
})
.collect();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
.title(format!(" Tasks ({} todo / {} done) ", todo, done))
.title_alignment(Alignment::Left);
let list = List::new(items)
.block(block)
.block(Block::default())
.highlight_style(
Style::default()
.bg(if focused { Color::DarkGray } else { Color::Black })
@@ -176,7 +160,73 @@ pub fn render_task_list(
)
.highlight_symbol(">> ");
frame.render_stateful_widget(list, area, &mut ratatui::widgets::ListState::default().with_selected(Some(selected)));
frame.render_stateful_widget(list, tasks_area, &mut ratatui::widgets::ListState::default().with_selected(Some(selected)));
}
fn render_list_tabs(
frame: &mut Frame,
area: Rect,
lists: &[TaskList],
selected_list: usize,
scroll: usize,
focused: bool,
) {
if lists.is_empty() || area.width < 4 {
return;
}
let max_width = area.width as usize;
let mut spans: Vec<Span> = Vec::new();
let mut used = if scroll > 0 {
spans.push(Span::styled(" <", Style::default().fg(Color::DarkGray)));
2
} else {
spans.push(Span::raw(" "));
1
};
let mut last_idx = scroll.wrapping_sub(1);
for i in scroll..lists.len() {
let title = &lists[i].title;
let title_len = title.chars().count();
let need_sep = if i > scroll { 3 } else { 0 };
let tab_space = title_len + 2;
if used + need_sep + tab_space > max_width {
break;
}
if need_sep > 0 {
spans.push(Span::raw(" | "));
used += 3;
}
let is_selected = i == selected_list;
let style = if is_selected && focused {
Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default().fg(FOCUS_COLOR)
} else {
Style::default().fg(Color::Cyan)
};
spans.push(Span::styled(format!(" {} ", title), style));
used += title_len + 2;
last_idx = i;
}
if last_idx + 1 < lists.len() && used + 5 <= max_width {
spans.push(Span::raw(" | "));
spans.push(Span::styled(">>", Style::default().fg(Color::DarkGray)));
}
if used < max_width {
spans.push(Span::raw(" ".repeat(max_width - used)));
}
let paragraph = Paragraph::new(Line::from(spans));
frame.render_widget(paragraph, area);
}
pub fn render_detail(
+7 -9
View File
@@ -11,7 +11,6 @@ use components::*;
#[derive(Debug, Clone, PartialEq)]
pub enum Focus {
Tabs,
TaskList,
Detail,
Calendar,
@@ -39,6 +38,7 @@ pub struct AppView<'a> {
pub lists: &'a [TaskList],
pub tasks: &'a [Task],
pub selected_list: usize,
pub list_tabs_scroll: usize,
pub selected_task: usize,
pub focus: Focus,
pub show_popup: Option<&'a Popup>,
@@ -68,20 +68,15 @@ pub fn draw(frame: &mut Frame, view: AppView) {
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(12),
Constraint::Length(1),
])
.split(area);
let tabs_area = main_layout[0];
let body_area = main_layout[1];
let calendar_area = main_layout[2];
let status_area = main_layout[3];
let is_tabs_focused = view.focus == Focus::Tabs;
render_tabs_bar(frame, tabs_area, view.lists, view.selected_list, is_tabs_focused);
let body_area = main_layout[0];
let calendar_area = main_layout[1];
let status_area = main_layout[2];
let body_layout = Layout::default()
.direction(Direction::Horizontal)
@@ -92,6 +87,9 @@ pub fn draw(frame: &mut Frame, view: AppView) {
render_task_list(
frame,
body_layout[0],
view.lists,
view.selected_list,
view.list_tabs_scroll,
view.tasks,
view.selected_task,
is_task_list_focused,