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
+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(