From 27139cf7cdfc93937bea6aae23588c3f7b1b8060 Mon Sep 17 00:00:00 2001 From: ruohki Date: Sat, 28 Feb 2026 23:49:21 +0100 Subject: [PATCH] feat(04-01): wire splash prepend and file metadata to status bar - Add PageMeta struct and read_page_meta() to app.rs with Gregorian date math - App::new() computes initial page_meta from vault path + current_path - navigate_to/back/forward all prepend splash lines and offset link_records for index.md - handle_resize re-prepends splash for index.md after re-render - Initial index.md load in main.rs prepends splash and adjusts link_records - Status bar right side now shows 'Last modified: Mon DD, YYYY | X.X KB' - Graceful truncation loop drops metadata fields when status bar is too narrow --- src/app.rs | 179 ++++++++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 13 +++- 2 files changed, 184 insertions(+), 8 deletions(-) diff --git a/src/app.rs b/src/app.rs index 58e3da3..a65c5f5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,7 +22,7 @@ use std::io; use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, UNIX_EPOCH}; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph}; @@ -30,6 +30,62 @@ use crate::config::Config; use crate::terminal::Term; use crate::signals::SignalFlags; +// ── PageMeta ────────────────────────────────────────────────────────────────── + +/// File metadata for status bar display. +struct PageMeta { + /// Human-readable last-modified date, e.g. "Feb 25, 2026". + modified: String, + /// Human-readable file size, e.g. "2.4 KB". + size: String, +} + +/// Read file metadata (mtime and size) for a given path. +/// +/// Returns `None` if the file cannot be stat'd. +fn read_page_meta(full_path: &Path) -> Option { + let meta = std::fs::metadata(full_path).ok()?; + let secs = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_secs(); + let (y, m, d) = unix_secs_to_ymd(secs); + let months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + Some(PageMeta { + modified: format!("{} {}, {}", months[(m - 1) as usize], d, y), + size: format_file_size(meta.len()), + }) +} + +/// Convert unix seconds to (year, month, day) using pure Gregorian arithmetic. +fn unix_secs_to_ymd(secs: u64) -> (u32, u32, u32) { + let mut days = (secs / 86400) as u32; + let mut year = 1970u32; + loop { + let days_in_year = if is_leap(year) { 366 } else { 365 }; + if days < days_in_year { break; } + days -= days_in_year; + year += 1; + } + let leap = is_leap(year); + let month_days: [u32; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let mut month = 0u32; + for (i, &d) in month_days.iter().enumerate() { + if days < d { month = i as u32 + 1; break; } + days -= d; + } + (year, month, days + 1) +} + +fn is_leap(year: u32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +fn format_file_size(bytes: u64) -> String { + match bytes { + 0..=1023 => format!("{} B", bytes), + 1024..=1_048_575 => format!("{:.1} KB", bytes as f64 / 1024.0), + _ => format!("{:.1} MB", bytes as f64 / 1_048_576.0), + } +} + /// How long the double-Ctrl+C window stays open before resetting. const DOUBLE_PRESS_WINDOW: Duration = Duration::from_secs(2); @@ -128,6 +184,11 @@ pub struct App { selected_link: Option, /// Current document's vault-relative path (e.g. "index.md", "guides/page.md"). current_path: String, + + // ── Phase 4 additions ───────────────────────────────────────────────────── + /// File metadata (mtime, size) for the currently displayed document. + /// None for error screens or when metadata cannot be read. + page_meta: Option, } impl App { @@ -139,6 +200,8 @@ impl App { /// `raw_content` is the raw markdown string (Some if Loaded) for resize re-render. /// `link_records` are the link metadata records from the initial render. /// `current_path` is the vault-relative path of the initial document. + /// + /// `page_meta` is computed automatically from `current_path` and `config.vault_path`. pub fn new( is_login_shell: bool, config: Config, @@ -151,6 +214,14 @@ impl App { DocumentState::Loaded { filename, .. } => filename.clone(), DocumentState::Missing { .. } | DocumentState::Error { .. } => "ERROR".to_string(), }; + // Compute initial page metadata from the loaded document's path + let page_meta = match &document { + DocumentState::Loaded { .. } => { + let full_path = config.vault_path.join(¤t_path); + read_page_meta(&full_path) + } + _ => None, + }; // Initialize history with one entry for the initial page let initial_history = vec![HistoryEntry { path: current_path.clone(), @@ -173,6 +244,7 @@ impl App { link_records, selected_link: None, current_path, + page_meta, } } @@ -239,13 +311,30 @@ impl App { /// /// ratatui handles the buffer resize automatically for `Viewport::Fullscreen`. /// We re-render so horizontal rules, code block borders, and table widths adapt. + /// On index.md, splash lines are re-prepended after the re-render. fn handle_resize(&mut self, new_width: u16) { if let Some(ref content) = self.raw_content.clone() { - let (lines, link_records) = crate::renderer::render_markdown( + let vault_path = self.config.vault_path.clone(); + let (mut lines, mut link_records) = crate::renderer::render_markdown( content, new_width, - Some(&self.config.vault_path), + Some(&vault_path), ); + + // Prepend splash lines for index.md (same logic as navigate_to) + if self.current_path == "index.md" { + if let Some(mut splash_lines) = crate::splash::load_splash(&vault_path) { + let splash_count = splash_lines.len() + 1; // +1 for blank separator + splash_lines.push(Line::default()); + splash_lines.extend(lines); + lines = splash_lines; + // Offset link_records by the number of splash lines added + for record in &mut link_records { + record.line_index += splash_count; + } + } + } + self.link_records = link_records; // Preserve selected_link if still valid after re-render if let Some(i) = self.selected_link { @@ -377,9 +466,27 @@ impl App { let width = ratatui::crossterm::terminal::size() .map(|(w, _)| w) .unwrap_or(80); - let (lines, link_records) = + let (mut lines, mut link_records) = crate::renderer::render_markdown(&content, width, Some(&vault_path)); + // Read file metadata for status bar + let full_path = vault_path.join(vault_relative); + self.page_meta = read_page_meta(&full_path); + + // Prepend splash art for index.md + if vault_relative == "index.md" { + if let Some(mut splash_lines) = crate::splash::load_splash(&vault_path) { + let splash_count = splash_lines.len() + 1; // +1 for blank separator + splash_lines.push(Line::default()); + splash_lines.extend(lines); + lines = splash_lines; + // Offset link_records so Tab-cycling still targets correct lines + for record in &mut link_records { + record.line_index += splash_count; + } + } + } + self.document = DocumentState::Loaded { filename: filename.clone(), lines, @@ -405,12 +512,14 @@ impl App { self.raw_content = None; self.link_records = Vec::new(); self.selected_link = None; + self.page_meta = None; } crate::vault::VaultDocument::ReadError { path, reason } => { self.document = DocumentState::Error { path, reason }; self.raw_content = None; self.link_records = Vec::new(); self.selected_link = None; + self.page_meta = None; } } } @@ -444,9 +553,26 @@ impl App { let width = ratatui::crossterm::terminal::size() .map(|(w, _)| w) .unwrap_or(80); - let (lines, link_records) = + let (mut lines, mut link_records) = crate::renderer::render_markdown(&content, width, Some(&vault_path)); + // Read file metadata for status bar + let full_path = vault_path.join(&target_path); + self.page_meta = read_page_meta(&full_path); + + // Prepend splash art for index.md + if target_path == "index.md" { + if let Some(mut splash_lines) = crate::splash::load_splash(&vault_path) { + let splash_count = splash_lines.len() + 1; + splash_lines.push(Line::default()); + splash_lines.extend(lines); + lines = splash_lines; + for record in &mut link_records { + record.line_index += splash_count; + } + } + } + self.document = DocumentState::Loaded { filename: filename.clone(), lines, @@ -489,9 +615,26 @@ impl App { let width = ratatui::crossterm::terminal::size() .map(|(w, _)| w) .unwrap_or(80); - let (lines, link_records) = + let (mut lines, mut link_records) = crate::renderer::render_markdown(&content, width, Some(&vault_path)); + // Read file metadata for status bar + let full_path = vault_path.join(&target_path); + self.page_meta = read_page_meta(&full_path); + + // Prepend splash art for index.md + if target_path == "index.md" { + if let Some(mut splash_lines) = crate::splash::load_splash(&vault_path) { + let splash_count = splash_lines.len() + 1; + splash_lines.push(Line::default()); + splash_lines.extend(lines); + lines = splash_lines; + for record in &mut link_records { + record.line_index += splash_count; + } + } + } + self.document = DocumentState::Loaded { filename: filename.clone(), lines, @@ -747,6 +890,11 @@ impl App { right_parts.push("Forward >".to_string()); } + // File metadata: "Last modified: Feb 25, 2026 | 2.4 KB" + if let Some(ref meta) = self.page_meta { + right_parts.push(format!("Last modified: {} | {}", meta.modified, meta.size)); + } + // Keyboard hints let hints = if self.is_login_shell { "Tab:Links Enter:Go Bksp:Back Ctrl+C\u{00D7}2:Quit" @@ -755,7 +903,24 @@ impl App { }; right_parts.push(hints.to_string()); - let right = format!(" {} ", right_parts.join(" ")); + // Gracefully truncate right side if it overflows the status bar width. + // Strategy: progressively drop rightmost metadata items (size, then full meta, then hints) + // until the bar fits within the terminal width. + let right = loop { + let candidate = format!(" {} ", right_parts.join(" ")); + if left.len() + candidate.len() <= width || right_parts.len() <= 1 { + break candidate; + } + // Drop the hints-adjacent item (metadata is between nav parts and hints) + // Hints is always last, metadata is second-to-last when present. + // If we can't fit, remove the item before hints. + let hints_idx = right_parts.len() - 1; + if hints_idx > 0 { + right_parts.remove(hints_idx - 1); + } else { + break format!(" {} ", right_parts.join(" ")); + } + }; let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len()); let padding = " ".repeat(pad_len); diff --git a/src/main.rs b/src/main.rs index 62f3cd2..7dba136 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,11 +46,22 @@ fn main() { .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "index.md".to_string()); - let (lines, link_records) = renderer::render_markdown( + let (mut lines, mut link_records) = renderer::render_markdown( &content, initial_width, Some(&app_config.vault_path), ); + // Prepend ANSI art splash screen for index.md (Phase 4) + if let Some(mut splash_lines) = splash::load_splash(&app_config.vault_path) { + let splash_count = splash_lines.len() + 1; // +1 for blank separator + splash_lines.push(ratatui::text::Line::default()); + splash_lines.extend(lines); + lines = splash_lines; + // Offset all link_records so Tab-cycling targets the correct lines + for record in &mut link_records { + record.line_index += splash_count; + } + } let doc = app::DocumentState::Loaded { filename, lines }; (doc, Some(content), link_records) }