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
This commit is contained in:
+172
-7
@@ -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<PageMeta> {
|
||||
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<usize>,
|
||||
/// 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<PageMeta>,
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user