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::domain::models::*;
|
||||||
use crate::infrastructure::api::ApiClient;
|
use crate::infrastructure::api::ApiClient;
|
||||||
use crate::infrastructure::db::Db;
|
use crate::infrastructure::db::Db;
|
||||||
use crate::ui::{Focus, NetworkStatus, Popup};
|
use crate::ui::{Focus, NetworkStatus, Page, Popup};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct SyncStats {
|
pub struct SyncStats {
|
||||||
@@ -22,6 +22,7 @@ pub struct SyncStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
|
pub current_page: Page,
|
||||||
pub lists: Vec<TaskList>,
|
pub lists: Vec<TaskList>,
|
||||||
pub tasks: Vec<Task>,
|
pub tasks: Vec<Task>,
|
||||||
pub calendar_events: Vec<CalendarEvent>,
|
pub calendar_events: Vec<CalendarEvent>,
|
||||||
@@ -58,6 +59,7 @@ pub struct App {
|
|||||||
pub bulk_action_selected: usize,
|
pub bulk_action_selected: usize,
|
||||||
pub popup_list_indices: Vec<(String, String)>,
|
pub popup_list_indices: Vec<(String, String)>,
|
||||||
pub popup_list_selected: usize,
|
pub popup_list_selected: usize,
|
||||||
|
pub page_switcher_selected: usize,
|
||||||
auth_tx: std_mpsc::Sender<AuthEvent>,
|
auth_tx: std_mpsc::Sender<AuthEvent>,
|
||||||
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
||||||
sync_tx: mpsc::Sender<SyncCommand>,
|
sync_tx: mpsc::Sender<SyncCommand>,
|
||||||
@@ -100,6 +102,7 @@ impl App {
|
|||||||
sort_tasks(&mut tasks);
|
sort_tasks(&mut tasks);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
current_page: Page::Tasks,
|
||||||
lists,
|
lists,
|
||||||
tasks,
|
tasks,
|
||||||
calendar_events: Vec::new(),
|
calendar_events: Vec::new(),
|
||||||
@@ -135,6 +138,7 @@ impl App {
|
|||||||
bulk_action_selected: 0,
|
bulk_action_selected: 0,
|
||||||
popup_list_indices: Vec::new(),
|
popup_list_indices: Vec::new(),
|
||||||
popup_list_selected: 0,
|
popup_list_selected: 0,
|
||||||
|
page_switcher_selected: 0,
|
||||||
auth_tx,
|
auth_tx,
|
||||||
auth_rx,
|
auth_rx,
|
||||||
sync_tx,
|
sync_tx,
|
||||||
@@ -329,6 +333,15 @@ impl App {
|
|||||||
return;
|
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 {
|
match key.code {
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
match self.focus {
|
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();
|
app.refresh_if_needed();
|
||||||
|
|
||||||
let view = AppView {
|
let view = AppView {
|
||||||
|
current_page: &app.current_page,
|
||||||
lists: &app.lists,
|
lists: &app.lists,
|
||||||
tasks: &app.tasks,
|
tasks: &app.tasks,
|
||||||
calendar_events: &app.calendar_events,
|
calendar_events: &app.calendar_events,
|
||||||
@@ -181,6 +182,7 @@ fn main() -> io::Result<()> {
|
|||||||
bulk_action_selected: app.bulk_action_selected,
|
bulk_action_selected: app.bulk_action_selected,
|
||||||
popup_list_indices: &app.popup_list_indices,
|
popup_list_indices: &app.popup_list_indices,
|
||||||
popup_list_selected: app.popup_list_selected,
|
popup_list_selected: app.popup_list_selected,
|
||||||
|
page_switcher_selected: app.page_switcher_selected,
|
||||||
};
|
};
|
||||||
draw(frame, view);
|
draw(frame, view);
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
+62
-1
@@ -10,7 +10,7 @@ use ratatui::Frame;
|
|||||||
|
|
||||||
use crate::domain::models::*;
|
use crate::domain::models::*;
|
||||||
use crate::app::SyncStats;
|
use crate::app::SyncStats;
|
||||||
use super::NetworkStatus;
|
use super::{NetworkStatus, Page};
|
||||||
|
|
||||||
const FOCUS_COLOR: Color = Color::Yellow;
|
const FOCUS_COLOR: Color = Color::Yellow;
|
||||||
const SELECTED_COLOR: Color = Color::Green;
|
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(
|
pub fn render_task_list(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@@ -642,6 +666,43 @@ pub fn render_pick_list_popup(
|
|||||||
frame.render_widget(paragraph, popup_area);
|
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(
|
pub fn render_device_auth_popup(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
|
|||||||
+65
-29
@@ -2,7 +2,10 @@ pub mod components;
|
|||||||
|
|
||||||
use std::collections::BTreeSet;
|
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 ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::SyncStats;
|
use crate::app::SyncStats;
|
||||||
@@ -16,6 +19,12 @@ pub enum Focus {
|
|||||||
Calendar,
|
Calendar,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Page {
|
||||||
|
Tasks,
|
||||||
|
Markdown,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum Popup {
|
pub enum Popup {
|
||||||
Input,
|
Input,
|
||||||
@@ -24,6 +33,7 @@ pub enum Popup {
|
|||||||
ConfirmDelete { context: String },
|
ConfirmDelete { context: String },
|
||||||
BulkAction,
|
BulkAction,
|
||||||
PickList,
|
PickList,
|
||||||
|
PageSwitcher,
|
||||||
DeviceAuth { url: String, code: String },
|
DeviceAuth { url: String, code: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +45,7 @@ pub enum NetworkStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppView<'a> {
|
pub struct AppView<'a> {
|
||||||
|
pub current_page: &'a Page,
|
||||||
pub lists: &'a [TaskList],
|
pub lists: &'a [TaskList],
|
||||||
pub tasks: &'a [Task],
|
pub tasks: &'a [Task],
|
||||||
pub selected_list: usize,
|
pub selected_list: usize,
|
||||||
@@ -60,6 +71,7 @@ pub struct AppView<'a> {
|
|||||||
pub bulk_action_selected: usize,
|
pub bulk_action_selected: usize,
|
||||||
pub popup_list_indices: &'a [(String, String)],
|
pub popup_list_indices: &'a [(String, String)],
|
||||||
pub popup_list_selected: usize,
|
pub popup_list_selected: usize,
|
||||||
|
pub page_switcher_selected: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, view: AppView) {
|
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()
|
let main_layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
Constraint::Min(0),
|
Constraint::Min(0),
|
||||||
Constraint::Length(12),
|
Constraint::Length(12),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
let body_area = main_layout[0];
|
let menu_area = main_layout[0];
|
||||||
let calendar_area = main_layout[1];
|
let body_area = main_layout[1];
|
||||||
let status_area = main_layout[2];
|
let calendar_area = main_layout[2];
|
||||||
|
let status_area = main_layout[3];
|
||||||
|
|
||||||
let body_layout = Layout::default()
|
render_page_menu(frame, menu_area, view.current_page);
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
|
||||||
.split(body_area);
|
|
||||||
|
|
||||||
let is_task_list_focused = view.focus == Focus::TaskList;
|
match view.current_page {
|
||||||
render_task_list(
|
Page::Tasks => {
|
||||||
frame,
|
let body_layout = Layout::default()
|
||||||
body_layout[0],
|
.direction(Direction::Horizontal)
|
||||||
view.lists,
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
view.selected_list,
|
.split(body_area);
|
||||||
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;
|
let is_task_list_focused = view.focus == Focus::TaskList;
|
||||||
render_detail(
|
render_task_list(
|
||||||
frame,
|
frame,
|
||||||
body_layout[1],
|
body_layout[0],
|
||||||
view.tasks.get(view.selected_task),
|
view.lists,
|
||||||
is_detail_focused,
|
view.selected_list,
|
||||||
view.detail_scroll,
|
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;
|
let is_calendar_focused = view.focus == Focus::Calendar;
|
||||||
render_calendar_panel(
|
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::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::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::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),
|
Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user