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:
2026-02-28 23:49:21 +01:00
parent 6aa5a940f2
commit 27139cf7cd
2 changed files with 184 additions and 8 deletions
+172 -7
View File
@@ -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(&current_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);
+12 -1
View File
@@ -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)
}