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:
+35
-1
@@ -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;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user