feat: página Markdown com explorador de ficheiros e preview

- Calendar movido para dentro da página Tasks (já não global)
- Página Markdown: 30% file explorer + 70% preview
- File explorer lista .md do diretório ~/dev/notes (configurável)
- Ctrl+O altera diretório markdown
- Ctrl+P Page Switcher navega entre Tasks e Markdown
- Up/Down no file explorer carrega preview automaticamente
- Focus::FileExplorer + Focus::MarkdownPreview no ciclo Tab
- Bump ratatui 0.28 → 0.30; adicionado tui-markdown 0.3.7
This commit is contained in:
2026-06-26 15:06:42 +01:00
parent a0e2aeca63
commit fb74d29e3a
7 changed files with 1587 additions and 128 deletions
Generated
+1330 -77
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -4,7 +4,8 @@ version = "0.1.0"
edition = "2021"
[dependencies]
ratatui = "0.28"
ratatui = "0.30"
tui-markdown = "0.3.7"
crossterm = "0.28"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+6 -1
View File
@@ -11,10 +11,13 @@ Aplicação TUI de gestão de tarefas com sincronização Google Tasks.
- Reordenação de tarefas com persistência
- Operações CRUD em listas e tarefas
- Seleção múltipla e ações em lote
- **Página Markdown**: exploração e pré-visualização de ficheiros `.md`
- **Page Switcher**: alternar entre Tasks e Markdown
## Stack
- **UI:** ratatui + crossterm
- **UI:** ratatui 0.30 + crossterm
- **Markdown:** tui-markdown
- **Async:** tokio
- **DB:** rusqlite (SQLite)
- **Auth:** yup-oauth2 (OAuth 2.0)
@@ -54,4 +57,6 @@ cargo run
| `Enter` | Completar/descompletar tarefa |
| `t` | Definir data (d=today, t=tomorrow, w=week, m=month) |
| `Ctrl+r` | Sincronização forçada |
| `Ctrl+p` | Abrir Page Switcher (Tasks / Markdown) |
| `Ctrl+o` | Alterar diretório de ficheiros Markdown |
| `q` | Sair |
+106 -7
View File
@@ -10,6 +10,8 @@ use tokio::sync::mpsc;
use crate::domain::models::*;
use crate::infrastructure::api::ApiClient;
use crate::infrastructure::db::Db;
use std::path::PathBuf;
use crate::ui::{Focus, NetworkStatus, Page, Popup};
#[derive(Debug, Clone, Default)]
@@ -42,6 +44,7 @@ pub struct App {
pub task_list_scroll: u16,
pub detail_scroll: u16,
pub notes_scroll: u16,
pub markdown_scroll: u16,
pub calendar_scrolls: [u16; 4],
pub calendar_active_week: usize,
pub db: Arc<Db>,
@@ -60,6 +63,11 @@ pub struct App {
pub popup_list_indices: Vec<(String, String)>,
pub popup_list_selected: usize,
pub page_switcher_selected: usize,
pub markdown_dir: String,
pub markdown_files: Vec<String>,
markdown_file_paths: Vec<PathBuf>,
pub markdown_selected_file: usize,
pub markdown_content: String,
auth_tx: std_mpsc::Sender<AuthEvent>,
auth_rx: std_mpsc::Receiver<AuthEvent>,
sync_tx: mpsc::Sender<SyncCommand>,
@@ -122,6 +130,7 @@ impl App {
task_list_scroll: 0,
detail_scroll: 0,
notes_scroll: 0,
markdown_scroll: 0,
calendar_scrolls: [0; 4],
calendar_active_week: 0,
db,
@@ -139,6 +148,11 @@ impl App {
popup_list_indices: Vec::new(),
popup_list_selected: 0,
page_switcher_selected: 0,
markdown_dir: expand_tilde("~/dev/notes"),
markdown_files: Vec::new(),
markdown_file_paths: Vec::new(),
markdown_selected_file: 0,
markdown_content: String::new(),
auth_tx,
auth_rx,
sync_tx,
@@ -342,6 +356,13 @@ impl App {
return;
}
if key.code == KeyCode::Char('o') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.popup_input = self.markdown_dir.clone();
self.popup_cursor = self.popup_input.len();
self.show_popup = Some(Popup::Input);
return;
}
match key.code {
KeyCode::Tab => {
match self.focus {
@@ -350,14 +371,21 @@ impl App {
self.calendar_active_week += 1;
} else {
self.calendar_active_week = 0;
self.focus = Focus::TaskList;
self.focus = match self.current_page {
Page::Tasks => Focus::TaskList,
Page::Markdown => Focus::FileExplorer,
};
}
}
_ => {
self.focus = match self.focus {
Focus::TaskList => Focus::Detail,
Focus::Detail => Focus::Calendar,
Focus::Calendar => Focus::TaskList,
self.focus = match (&self.current_page, &self.focus) {
(Page::Tasks, Focus::TaskList) => Focus::Detail,
(Page::Tasks, Focus::Detail) => Focus::Calendar,
(Page::Tasks, Focus::Calendar) => Focus::TaskList,
(Page::Tasks, _) => Focus::TaskList,
(Page::Markdown, Focus::FileExplorer) => Focus::MarkdownPreview,
(Page::Markdown, Focus::MarkdownPreview) => Focus::FileExplorer,
(Page::Markdown, _) => Focus::FileExplorer,
};
}
}
@@ -402,6 +430,15 @@ impl App {
Focus::Calendar => {
self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_sub(1);
}
Focus::FileExplorer => {
if self.markdown_selected_file > 0 {
self.markdown_selected_file -= 1;
self.load_markdown_file();
}
}
Focus::MarkdownPreview => {
self.markdown_scroll = self.markdown_scroll.saturating_sub(1);
}
},
KeyCode::Down => match self.focus {
Focus::TaskList => {
@@ -417,6 +454,15 @@ impl App {
Focus::Calendar => {
self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_add(1);
}
Focus::FileExplorer => {
if self.markdown_selected_file + 1 < self.markdown_files.len() {
self.markdown_selected_file += 1;
self.load_markdown_file();
}
}
Focus::MarkdownPreview => {
self.markdown_scroll += 1;
}
},
KeyCode::Right => {
match self.focus {
@@ -568,11 +614,15 @@ impl App {
}
KeyCode::Enter => {
let input = self.popup_input.trim().to_string();
if self.pending_bulk_move && !input.is_empty() {
if self.current_page == Page::Markdown && !input.is_empty() {
self.markdown_dir = expand_tilde(&input);
self.scan_markdown_files();
self.show_popup = None;
} else if self.pending_bulk_move && !input.is_empty() {
self.pending_bulk_move = false;
self.bulk_move_to_new_list(&input);
self.show_popup = None;
} else if !input.is_empty() {
} else if !input.is_empty() {
let list = TaskList {
id: uuid_v4(),
title: input,
@@ -956,6 +1006,13 @@ impl App {
0 => Page::Tasks,
_ => Page::Markdown,
};
self.focus = match self.current_page {
Page::Tasks => Focus::TaskList,
Page::Markdown => Focus::FileExplorer,
};
if let Page::Markdown = self.current_page {
self.scan_markdown_files();
}
self.show_popup = None;
}
_ => {}
@@ -1257,6 +1314,39 @@ impl App {
}
}
pub fn scan_markdown_files(&mut self) {
let dir = PathBuf::from(&self.markdown_dir);
self.markdown_files.clear();
self.markdown_file_paths.clear();
self.markdown_selected_file = 0;
self.markdown_content.clear();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(false, |e| e == "md") {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
self.markdown_files.push(name.to_string());
self.markdown_file_paths.push(path);
}
}
}
}
self.markdown_files.sort();
self.markdown_file_paths.sort();
self.load_markdown_file();
}
pub fn load_markdown_file(&mut self) {
self.markdown_content.clear();
if self.markdown_selected_file < self.markdown_file_paths.len() {
let path = &self.markdown_file_paths[self.markdown_selected_file];
if let Ok(content) = std::fs::read_to_string(path) {
self.markdown_content = content;
}
}
}
fn load_lists(&mut self) {
self.lists = self.db.get_lists();
let max_scroll = self.lists.len().saturating_sub(4);
@@ -1298,6 +1388,15 @@ fn sort_tasks(tasks: &mut Vec<Task>) {
});
}
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return format!("{}/{}", home, rest);
}
}
path.to_string()
}
fn uuid_v4() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
+4
View File
@@ -174,6 +174,7 @@ fn main() -> io::Result<()> {
task_list_scroll: app.task_list_scroll,
detail_scroll: app.detail_scroll,
notes_scroll: app.notes_scroll,
markdown_scroll: app.markdown_scroll,
calendar_scrolls: &app.calendar_scrolls,
calendar_active_week: app.calendar_active_week,
auth_error: app.auth_error.as_deref(),
@@ -183,6 +184,9 @@ fn main() -> io::Result<()> {
popup_list_indices: &app.popup_list_indices,
popup_list_selected: app.popup_list_selected,
page_switcher_selected: app.page_switcher_selected,
markdown_files: &app.markdown_files,
markdown_selected_file: app.markdown_selected_file,
markdown_content: &app.markdown_content,
};
draw(frame, view);
})?;
+93 -11
View File
@@ -47,22 +47,104 @@ pub fn render_page_menu(frame: &mut Frame, area: Rect, current_page: &Page) {
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 items = [(Page::Tasks, " Tasks "), (Page::Markdown, " Markdown ")];
let text = Line::from(vec![
Span::raw(" "),
Span::styled(" Tasks ", style),
]);
let mut spans = vec![Span::raw(" ")];
for (i, (page, label)) in items.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" | "));
}
let active = current_page == page;
let style = if active {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
spans.push(Span::styled(*label, style));
}
let paragraph = Paragraph::new(text);
let paragraph = Paragraph::new(Line::from(spans));
frame.render_widget(paragraph, inner);
}
pub fn render_file_explorer(
frame: &mut Frame,
area: Rect,
files: &[String],
selected: usize,
focused: bool,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
.title(" Files ")
.title_alignment(Alignment::Left);
let inner = block.inner(area);
frame.render_widget(&block, area);
let items: Vec<ListItem> = files
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == selected {
Style::default()
.fg(FOCUS_COLOR)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
ListItem::new(Line::from(Span::styled(format!(" {}", name), style)))
})
.collect();
let list = List::new(items)
.highlight_style(
Style::default()
.bg(if focused { Color::DarkGray } else { Color::Black })
.fg(SELECTED_COLOR)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
frame.render_stateful_widget(
list,
inner,
&mut ratatui::widgets::ListState::default().with_selected(Some(selected)),
);
}
pub fn render_markdown_preview(
frame: &mut Frame,
area: Rect,
content: &str,
focused: bool,
scroll: u16,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
.title(" Preview ")
.title_alignment(Alignment::Left);
if content.is_empty() {
let text = Text::from(Line::from(Span::styled(
"Select a file to preview",
Style::default().fg(Color::DarkGray),
)));
let paragraph = Paragraph::new(text).block(block).alignment(Alignment::Center);
frame.render_widget(paragraph, area);
} else {
let md_text = tui_markdown::from_str(content);
let lines: Vec<Line> = md_text.lines.into_iter().map(|l| l).collect();
let paragraph = Paragraph::new(Text::from(lines))
.block(block)
.scroll((scroll, 0))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
}
pub fn render_task_list(
frame: &mut Frame,
area: Rect,
+46 -31
View File
@@ -2,10 +2,7 @@ pub mod components;
use std::collections::BTreeSet;
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::layout::{Constraint, Direction, Layout};
use ratatui::Frame;
use crate::app::SyncStats;
@@ -17,6 +14,8 @@ pub enum Focus {
TaskList,
Detail,
Calendar,
FileExplorer,
MarkdownPreview,
}
#[derive(Debug, Clone, PartialEq)]
@@ -62,6 +61,7 @@ pub struct AppView<'a> {
pub task_list_scroll: u16,
pub detail_scroll: u16,
pub notes_scroll: u16,
pub markdown_scroll: u16,
pub calendar_events: &'a [CalendarEvent],
pub calendar_scrolls: &'a [u16; 4],
pub calendar_active_week: usize,
@@ -72,6 +72,9 @@ pub struct AppView<'a> {
pub popup_list_indices: &'a [(String, String)],
pub popup_list_selected: usize,
pub page_switcher_selected: usize,
pub markdown_files: &'a [String],
pub markdown_selected_file: usize,
pub markdown_content: &'a str,
}
pub fn draw(frame: &mut Frame, view: AppView) {
@@ -82,24 +85,27 @@ pub fn draw(frame: &mut Frame, view: AppView) {
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(12),
Constraint::Length(1),
])
.split(area);
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 status_area = main_layout[2];
render_page_menu(frame, menu_area, view.current_page);
match view.current_page {
Page::Tasks => {
let tasks_cal = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(12)])
.split(body_area);
let body_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(body_area);
.split(tasks_cal[0]);
let is_task_list_focused = view.focus == Focus::TaskList;
render_task_list(
@@ -123,34 +129,43 @@ pub fn draw(frame: &mut Frame, view: AppView) {
is_detail_focused,
view.detail_scroll,
);
let is_calendar_focused = view.focus == Focus::Calendar;
render_calendar_panel(
frame,
tasks_cal[1],
view.calendar_events,
is_calendar_focused,
view.calendar_scrolls,
view.calendar_active_week,
);
}
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 md_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(body_area);
let is_explorer_focused = view.focus == Focus::FileExplorer;
render_file_explorer(
frame,
md_layout[0],
view.markdown_files,
view.markdown_selected_file,
is_explorer_focused,
);
let is_preview_focused = view.focus == Focus::MarkdownPreview;
render_markdown_preview(
frame,
md_layout[1],
view.markdown_content,
is_preview_focused,
view.markdown_scroll,
);
}
}
let is_calendar_focused = view.focus == Focus::Calendar;
render_calendar_panel(
frame,
calendar_area,
view.calendar_events,
is_calendar_focused,
view.calendar_scrolls,
view.calendar_active_week,
);
render_status_bar(frame, status_area, view.network_status, view.sync_stats);
if let Some(popup) = view.show_popup {