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:
+18
-47
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user