feat: adicionar menu de páginas e Page Switcher (Ctrl+P)

- 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
This commit is contained in:
2026-06-26 14:35:39 +01:00
parent 0c842c0e14
commit a0e2aeca63
4 changed files with 164 additions and 31 deletions
+35 -1
View File
@@ -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<TaskList>,
pub tasks: Vec<Task>,
pub calendar_events: Vec<CalendarEvent>,
@@ -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<AuthEvent>,
auth_rx: std_mpsc::Receiver<AuthEvent>,
sync_tx: mpsc::Sender<SyncCommand>,
@@ -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;
}
_ => {}
},
}
}
+2
View File
@@ -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);
})?;
+62 -1
View File
@@ -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,
+65 -29
View File
@@ -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),
}
}