From 0c842c0e14eca8fb5f49ca83a16d8bea91bc456a Mon Sep 17 00:00:00 2001 From: rubenrosario Date: Fri, 26 Jun 2026 14:08:51 +0100 Subject: [PATCH] feat: integrar painel de listas no painel de tarefas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app.rs | 117 ++++++++++++++----------------------- src/main.rs | 1 + src/ui/components.rs | 134 +++++++++++++++++++++++++++++-------------- src/ui/mod.rs | 16 +++--- 4 files changed, 144 insertions(+), 124 deletions(-) diff --git a/src/app.rs b/src/app.rs index f53a058..45cf725 100644 --- a/src/app.rs +++ b/src/app.rs @@ -26,6 +26,7 @@ pub struct App { pub tasks: Vec, pub calendar_events: Vec, pub selected_list: usize, + pub list_tabs_scroll: usize, pub selected_task: usize, pub focus: Focus, pub show_popup: Option, @@ -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 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() - } - } + 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,55 +845,28 @@ 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(); - } + if !self.tasks.is_empty() && self.selected_task < self.tasks.len() { + let task = &self.tasks[self.selected_task]; + let task_id = task.id.clone(); + let list_id = task.list_id.clone(); + let title = task.title.clone(); + self.db.delete_task(&task_id).ok(); + if !list_id.contains('-') { + self.db.push_sync( + SyncAction::Delete, + &task_id, + &list_id, + "", + ).ok(); } - 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(); - let list_id = task.list_id.clone(); - let title = task.title.clone(); - self.db.delete_task(&task_id).ok(); - if !list_id.contains('-') { - self.db.push_sync( - SyncAction::Delete, - &task_id, - &list_id, - "", - ).ok(); - } - crate::log_msg(&format!( - "[task_app] TASK DELETE: title=\"{}\" id={} list={}", - title, task_id, list_id - )); - self.trigger_sync(); - self.load_tasks(); - if self.selected_task >= self.tasks.len() { - self.selected_task = self.tasks.len().saturating_sub(1); - } - } + crate::log_msg(&format!( + "[task_app] TASK DELETE: title=\"{}\" id={} list={}", + title, task_id, list_id + )); + self.trigger_sync(); + self.load_tasks(); + if self.selected_task >= self.tasks.len() { + 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) { diff --git a/src/main.rs b/src/main.rs index 1427914..b31f4e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(), diff --git a/src/ui/components.rs b/src/ui/components.rs index e3a8315..dbdb59c 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -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::>()) - .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 = 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 = 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( diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 009e30e..b53b1ef 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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,