From a0e2aeca6376d312594cb45c5ee1b49adf134db6 Mon Sep 17 00:00:00 2001 From: rubenrosario Date: Fri, 26 Jun 2026 14:35:39 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20adicionar=20menu=20de=20p=C3=A1ginas=20?= =?UTF-8?q?e=20Page=20Switcher=20(Ctrl+P)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nova barra de páginas no topo (3 linhas) com moldura - Página Tasks: body com lista + detalhe (comportamento normal) - Página Markdown: placeholder (preparado para Feature 3) - Calendar sempre visível no fundo, Tab cycle mantido - Ctrl+P abre Page Switcher popup (navegação com ↑/↓/Enter/Esc) - Enum Page: Tasks, Markdown - Page::Markdown já funcional no switch mas sem preview real --- src/app.rs | 36 ++++++++++++++++- src/main.rs | 2 + src/ui/components.rs | 63 ++++++++++++++++++++++++++++- src/ui/mod.rs | 94 ++++++++++++++++++++++++++++++-------------- 4 files changed, 164 insertions(+), 31 deletions(-) diff --git a/src/app.rs b/src/app.rs index 45cf725..b1cc262 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,7 @@ use tokio::sync::mpsc; use crate::domain::models::*; use crate::infrastructure::api::ApiClient; use crate::infrastructure::db::Db; -use crate::ui::{Focus, NetworkStatus, Popup}; +use crate::ui::{Focus, NetworkStatus, Page, Popup}; #[derive(Debug, Clone, Default)] pub struct SyncStats { @@ -22,6 +22,7 @@ pub struct SyncStats { } pub struct App { + pub current_page: Page, pub lists: Vec, pub tasks: Vec, pub calendar_events: Vec, @@ -58,6 +59,7 @@ pub struct App { pub bulk_action_selected: usize, pub popup_list_indices: Vec<(String, String)>, pub popup_list_selected: usize, + pub page_switcher_selected: usize, auth_tx: std_mpsc::Sender, auth_rx: std_mpsc::Receiver, sync_tx: mpsc::Sender, @@ -100,6 +102,7 @@ impl App { sort_tasks(&mut tasks); Self { + current_page: Page::Tasks, lists, tasks, calendar_events: Vec::new(), @@ -135,6 +138,7 @@ impl App { bulk_action_selected: 0, popup_list_indices: Vec::new(), popup_list_selected: 0, + page_switcher_selected: 0, auth_tx, auth_rx, sync_tx, @@ -329,6 +333,15 @@ impl App { return; } + if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.page_switcher_selected = match self.current_page { + Page::Tasks => 0, + Page::Markdown => 1, + }; + self.show_popup = Some(Popup::PageSwitcher); + return; + } + match key.code { KeyCode::Tab => { match self.focus { @@ -926,6 +939,27 @@ impl App { } _ => {} }, + Popup::PageSwitcher => match key.code { + KeyCode::Esc => { + self.show_popup = None; + } + KeyCode::Up => { + self.page_switcher_selected = self.page_switcher_selected.saturating_sub(1); + } + KeyCode::Down => { + if self.page_switcher_selected + 1 < 2 { + self.page_switcher_selected += 1; + } + } + KeyCode::Enter => { + self.current_page = match self.page_switcher_selected { + 0 => Page::Tasks, + _ => Page::Markdown, + }; + self.show_popup = None; + } + _ => {} + }, } } diff --git a/src/main.rs b/src/main.rs index b31f4e4..6b2c4a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -156,6 +156,7 @@ fn main() -> io::Result<()> { app.refresh_if_needed(); let view = AppView { + current_page: &app.current_page, lists: &app.lists, tasks: &app.tasks, calendar_events: &app.calendar_events, @@ -181,6 +182,7 @@ fn main() -> io::Result<()> { bulk_action_selected: app.bulk_action_selected, popup_list_indices: &app.popup_list_indices, popup_list_selected: app.popup_list_selected, + page_switcher_selected: app.page_switcher_selected, }; draw(frame, view); })?; diff --git a/src/ui/components.rs b/src/ui/components.rs index dbdb59c..c9f33e8 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -10,7 +10,7 @@ use ratatui::Frame; use crate::domain::models::*; use crate::app::SyncStats; -use super::NetworkStatus; +use super::{NetworkStatus, Page}; const FOCUS_COLOR: Color = Color::Yellow; const SELECTED_COLOR: Color = Color::Green; @@ -39,6 +39,30 @@ fn relative_due_str(due: chrono::NaiveDateTime) -> (String, Color) { } } +pub fn render_page_menu(frame: &mut Frame, area: Rect, current_page: &Page) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); + + let inner = block.inner(area); + frame.render_widget(&block, area); + + let active = current_page == &Page::Tasks; + let style = if active { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + + let text = Line::from(vec![ + Span::raw(" "), + Span::styled(" Tasks ", style), + ]); + + let paragraph = Paragraph::new(text); + frame.render_widget(paragraph, inner); +} + pub fn render_task_list( frame: &mut Frame, area: Rect, @@ -642,6 +666,43 @@ pub fn render_pick_list_popup( frame.render_widget(paragraph, popup_area); } +pub fn render_page_switcher_popup(frame: &mut Frame, area: Rect, selected: usize) { + let popup_area = centered_rect(50, 8, area); + frame.render_widget(Clear, popup_area); + let block = Block::default() + .borders(Borders::ALL) + .style(Style::default().bg(POPUP_BG)) + .border_style(Style::default().fg(POPUP_BORDER)) + .title(" Pages ") + .title_alignment(Alignment::Left); + + let pages: [&str; 2] = ["Tasks", "Markdown"]; + + let mut lines = vec![Line::from("")]; + for (i, title) in pages.iter().enumerate() { + let prefix = if i == selected { " > " } else { " " }; + let style = if i == selected { + Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + lines.push(Line::from(Span::styled( + format!("{}{}", prefix, title), + style, + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " \u{2191}/\u{2193}: navigate Enter:switch Esc:cancel", + Style::default().fg(Color::DarkGray), + ))); + + let paragraph = Paragraph::new(Text::from(lines)) + .block(block) + .alignment(Alignment::Left); + frame.render_widget(paragraph, popup_area); +} + pub fn render_device_auth_popup( frame: &mut Frame, area: Rect, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b53b1ef..577dfc6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,7 +2,10 @@ pub mod components; use std::collections::BTreeSet; -use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::layout::{Alignment, Constraint, Direction, Layout}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::app::SyncStats; @@ -16,6 +19,12 @@ pub enum Focus { Calendar, } +#[derive(Debug, Clone, PartialEq)] +pub enum Page { + Tasks, + Markdown, +} + #[derive(Debug, Clone, PartialEq)] pub enum Popup { Input, @@ -24,6 +33,7 @@ pub enum Popup { ConfirmDelete { context: String }, BulkAction, PickList, + PageSwitcher, DeviceAuth { url: String, code: String }, } @@ -35,6 +45,7 @@ pub enum NetworkStatus { } pub struct AppView<'a> { + pub current_page: &'a Page, pub lists: &'a [TaskList], pub tasks: &'a [Task], pub selected_list: usize, @@ -60,6 +71,7 @@ pub struct AppView<'a> { pub bulk_action_selected: usize, pub popup_list_indices: &'a [(String, String)], pub popup_list_selected: usize, + pub page_switcher_selected: usize, } pub fn draw(frame: &mut Frame, view: AppView) { @@ -68,43 +80,66 @@ 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 body_area = main_layout[0]; - let calendar_area = main_layout[1]; - let status_area = main_layout[2]; + let menu_area = main_layout[0]; + let body_area = main_layout[1]; + let calendar_area = main_layout[2]; + let status_area = main_layout[3]; - let body_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(body_area); + render_page_menu(frame, menu_area, view.current_page); - let is_task_list_focused = view.focus == Focus::TaskList; - 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, - view.task_list_scroll, - view.selected_tasks, - ); + match view.current_page { + Page::Tasks => { + let body_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(body_area); - let is_detail_focused = view.focus == Focus::Detail; - render_detail( - frame, - body_layout[1], - view.tasks.get(view.selected_task), - is_detail_focused, - view.detail_scroll, - ); + let is_task_list_focused = view.focus == Focus::TaskList; + 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, + view.task_list_scroll, + view.selected_tasks, + ); + + let is_detail_focused = view.focus == Focus::Detail; + render_detail( + frame, + body_layout[1], + view.tasks.get(view.selected_task), + is_detail_focused, + view.detail_scroll, + ); + } + Page::Markdown => { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Markdown ") + .title_alignment(Alignment::Left); + let text = Text::from(Line::from(Span::styled( + "Markdown preview coming soon", + Style::default().fg(Color::DarkGray), + ))); + let paragraph = Paragraph::new(text) + .block(block) + .alignment(Alignment::Center); + frame.render_widget(paragraph, body_area); + } + } let is_calendar_focused = view.focus == Focus::Calendar; render_calendar_panel( @@ -130,6 +165,7 @@ pub fn draw(frame: &mut Frame, view: AppView) { Popup::ConfirmDelete { context } => render_confirm_popup(frame, area, context), Popup::BulkAction => render_bulk_action_popup(frame, area, view.selected_tasks.len(), view.bulk_action_selected), Popup::PickList => render_pick_list_popup(frame, area, view.popup_list_indices, view.popup_list_selected), + Popup::PageSwitcher => render_page_switcher_popup(frame, area, view.page_switcher_selected), Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error), } }