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::io;
|
||||||
use std::path::{Path, PathBuf};
|
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::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
||||||
use ratatui::prelude::*;
|
use ratatui::prelude::*;
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
@@ -30,6 +30,62 @@ use crate::config::Config;
|
|||||||
use crate::terminal::Term;
|
use crate::terminal::Term;
|
||||||
use crate::signals::SignalFlags;
|
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.
|
/// How long the double-Ctrl+C window stays open before resetting.
|
||||||
const DOUBLE_PRESS_WINDOW: Duration = Duration::from_secs(2);
|
const DOUBLE_PRESS_WINDOW: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
@@ -128,6 +184,11 @@ pub struct App {
|
|||||||
selected_link: Option<usize>,
|
selected_link: Option<usize>,
|
||||||
/// Current document's vault-relative path (e.g. "index.md", "guides/page.md").
|
/// Current document's vault-relative path (e.g. "index.md", "guides/page.md").
|
||||||
current_path: String,
|
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 {
|
impl App {
|
||||||
@@ -139,6 +200,8 @@ impl App {
|
|||||||
/// `raw_content` is the raw markdown string (Some if Loaded) for resize re-render.
|
/// `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.
|
/// `link_records` are the link metadata records from the initial render.
|
||||||
/// `current_path` is the vault-relative path of the initial document.
|
/// `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(
|
pub fn new(
|
||||||
is_login_shell: bool,
|
is_login_shell: bool,
|
||||||
config: Config,
|
config: Config,
|
||||||
@@ -151,6 +214,14 @@ impl App {
|
|||||||
DocumentState::Loaded { filename, .. } => filename.clone(),
|
DocumentState::Loaded { filename, .. } => filename.clone(),
|
||||||
DocumentState::Missing { .. } | DocumentState::Error { .. } => "ERROR".to_string(),
|
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
|
// Initialize history with one entry for the initial page
|
||||||
let initial_history = vec![HistoryEntry {
|
let initial_history = vec![HistoryEntry {
|
||||||
path: current_path.clone(),
|
path: current_path.clone(),
|
||||||
@@ -173,6 +244,7 @@ impl App {
|
|||||||
link_records,
|
link_records,
|
||||||
selected_link: None,
|
selected_link: None,
|
||||||
current_path,
|
current_path,
|
||||||
|
page_meta,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,13 +311,30 @@ impl App {
|
|||||||
///
|
///
|
||||||
/// ratatui handles the buffer resize automatically for `Viewport::Fullscreen`.
|
/// ratatui handles the buffer resize automatically for `Viewport::Fullscreen`.
|
||||||
/// We re-render so horizontal rules, code block borders, and table widths adapt.
|
/// 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) {
|
fn handle_resize(&mut self, new_width: u16) {
|
||||||
if let Some(ref content) = self.raw_content.clone() {
|
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,
|
content,
|
||||||
new_width,
|
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;
|
self.link_records = link_records;
|
||||||
// Preserve selected_link if still valid after re-render
|
// Preserve selected_link if still valid after re-render
|
||||||
if let Some(i) = self.selected_link {
|
if let Some(i) = self.selected_link {
|
||||||
@@ -377,9 +466,27 @@ impl App {
|
|||||||
let width = ratatui::crossterm::terminal::size()
|
let width = ratatui::crossterm::terminal::size()
|
||||||
.map(|(w, _)| w)
|
.map(|(w, _)| w)
|
||||||
.unwrap_or(80);
|
.unwrap_or(80);
|
||||||
let (lines, link_records) =
|
let (mut lines, mut link_records) =
|
||||||
crate::renderer::render_markdown(&content, width, Some(&vault_path));
|
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 {
|
self.document = DocumentState::Loaded {
|
||||||
filename: filename.clone(),
|
filename: filename.clone(),
|
||||||
lines,
|
lines,
|
||||||
@@ -405,12 +512,14 @@ impl App {
|
|||||||
self.raw_content = None;
|
self.raw_content = None;
|
||||||
self.link_records = Vec::new();
|
self.link_records = Vec::new();
|
||||||
self.selected_link = None;
|
self.selected_link = None;
|
||||||
|
self.page_meta = None;
|
||||||
}
|
}
|
||||||
crate::vault::VaultDocument::ReadError { path, reason } => {
|
crate::vault::VaultDocument::ReadError { path, reason } => {
|
||||||
self.document = DocumentState::Error { path, reason };
|
self.document = DocumentState::Error { path, reason };
|
||||||
self.raw_content = None;
|
self.raw_content = None;
|
||||||
self.link_records = Vec::new();
|
self.link_records = Vec::new();
|
||||||
self.selected_link = None;
|
self.selected_link = None;
|
||||||
|
self.page_meta = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,9 +553,26 @@ impl App {
|
|||||||
let width = ratatui::crossterm::terminal::size()
|
let width = ratatui::crossterm::terminal::size()
|
||||||
.map(|(w, _)| w)
|
.map(|(w, _)| w)
|
||||||
.unwrap_or(80);
|
.unwrap_or(80);
|
||||||
let (lines, link_records) =
|
let (mut lines, mut link_records) =
|
||||||
crate::renderer::render_markdown(&content, width, Some(&vault_path));
|
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 {
|
self.document = DocumentState::Loaded {
|
||||||
filename: filename.clone(),
|
filename: filename.clone(),
|
||||||
lines,
|
lines,
|
||||||
@@ -489,9 +615,26 @@ impl App {
|
|||||||
let width = ratatui::crossterm::terminal::size()
|
let width = ratatui::crossterm::terminal::size()
|
||||||
.map(|(w, _)| w)
|
.map(|(w, _)| w)
|
||||||
.unwrap_or(80);
|
.unwrap_or(80);
|
||||||
let (lines, link_records) =
|
let (mut lines, mut link_records) =
|
||||||
crate::renderer::render_markdown(&content, width, Some(&vault_path));
|
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 {
|
self.document = DocumentState::Loaded {
|
||||||
filename: filename.clone(),
|
filename: filename.clone(),
|
||||||
lines,
|
lines,
|
||||||
@@ -747,6 +890,11 @@ impl App {
|
|||||||
right_parts.push("Forward >".to_string());
|
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
|
// Keyboard hints
|
||||||
let hints = if self.is_login_shell {
|
let hints = if self.is_login_shell {
|
||||||
"Tab:Links Enter:Go Bksp:Back Ctrl+C\u{00D7}2:Quit"
|
"Tab:Links Enter:Go Bksp:Back Ctrl+C\u{00D7}2:Quit"
|
||||||
@@ -755,7 +903,24 @@ impl App {
|
|||||||
};
|
};
|
||||||
right_parts.push(hints.to_string());
|
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 pad_len = width.saturating_sub(left.len()).saturating_sub(right.len());
|
||||||
let padding = " ".repeat(pad_len);
|
let padding = " ".repeat(pad_len);
|
||||||
|
|||||||
+12
-1
@@ -46,11 +46,22 @@ fn main() {
|
|||||||
.file_name()
|
.file_name()
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| "index.md".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,
|
&content,
|
||||||
initial_width,
|
initial_width,
|
||||||
Some(&app_config.vault_path),
|
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 };
|
let doc = app::DocumentState::Loaded { filename, lines };
|
||||||
(doc, Some(content), link_records)
|
(doc, Some(content), link_records)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user