use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph},
};
use crate::app::App;
use crate::ui::theme;
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let t = theme::cur();
let bg = t.bg_darker;
frame.render_widget(Block::default().style(Style::default().bg(bg)), area);
if area.height < 2 || area.width < 8 {
return;
}
app.rects.notes_panel_files.clear();
app.rects.notes_panel_new_chip = None;
app.rects.notes_panel_filter_input = None;
if !app.notes_panel_scanned_once {
app.notes_panel_refresh();
}
let filter_lc = app.notes_panel_filter.to_ascii_lowercase();
let all_files = app.notes_panel_files_cache.clone();
let files: Vec<std::path::PathBuf> = if filter_lc.is_empty() {
all_files.clone()
} else {
all_files
.iter()
.filter(|p| {
p.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.contains(&filter_lc)
})
.cloned()
.collect()
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
"NOTES",
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
if filter_lc.is_empty() {
String::new()
} else {
format!(" ({} of {})", files.len(), all_files.len())
},
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::DIM),
),
])),
Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
},
);
{
let y_filter = area.y + 1;
if y_filter < area.y + area.height {
let focused = app.notes_panel_filter_focused;
let bg_chip = t.bg2;
let fg_chip = if app.notes_panel_filter.is_empty() && !focused {
t.comment
} else {
t.fg
};
let display = if app.notes_panel_filter.is_empty() {
if focused {
"type to filter\u{2026}".to_string()
} else {
"/ filter".to_string()
}
} else {
app.notes_panel_filter.clone()
};
let cursor = if focused { "\u{258F}" } else { " " };
let pad = (area.width as usize).saturating_sub(3 + display.chars().count() + 1 + 1);
let line = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled("\u{F0349} ", Style::default().fg(t.comment).bg(bg_chip)),
Span::styled(display, Style::default().fg(fg_chip).bg(bg_chip)),
Span::styled(cursor, Style::default().fg(t.cyan).bg(bg_chip)),
Span::styled(" ".repeat(pad), Style::default().bg(bg_chip)),
Span::styled(" ", Style::default().bg(bg)),
]);
let row_rect = Rect {
x: area.x,
y: y_filter,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(line), row_rect);
app.rects.notes_panel_filter_input = Some(row_rect);
}
}
let mut y = area.y + 3;
if files.is_empty() && !filter_lc.is_empty() {
let empty = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
"No matches — Esc clears",
Style::default().fg(t.comment).bg(bg),
),
]);
frame.render_widget(
Paragraph::new(empty),
Rect {
x: area.x,
y,
width: area.width,
height: 1,
},
);
y += 2;
} else if files.is_empty() {
let empty = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
"No notes yet — click + New note below.",
Style::default().fg(t.comment).bg(bg),
),
]);
frame.render_widget(
Paragraph::new(empty),
Rect {
x: area.x,
y,
width: area.width,
height: 1,
},
);
y += 1;
let hint = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
"Stored under .mnml/notes/*.md",
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::DIM),
),
]);
frame.render_widget(
Paragraph::new(hint),
Rect {
x: area.x,
y,
width: area.width,
height: 1,
},
);
y += 2;
} else {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let clamped_cursor = app.notes_panel_cursor.min(files.len().saturating_sub(1));
app.notes_panel_cursor = clamped_cursor;
for (row_i, path) in files
.iter()
.take(area.height.saturating_sub(4) as usize)
.enumerate()
{
if y >= area.y + area.height {
break;
}
let is_focused_row = row_i == clamped_cursor;
let row_bg = if is_focused_row { t.bg2 } else { bg };
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("note")
.to_string();
let icon = if app.config.ui.ascii_icons {
"◧"
} else {
"\u{F249}"
};
let age_str: String = std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| {
let secs = now.saturating_sub(d.as_secs() as i64);
crate::ui::git_graph_view::humanize_age(secs)
})
.unwrap_or_default();
let name_width = (area.width as usize)
.saturating_sub(4)
.saturating_sub(age_str.chars().count())
.saturating_sub(1);
let name_clipped: String = name.chars().take(name_width).collect();
let name_padded = format!("{name_clipped:<width$}", width = name_width);
let row_rect = Rect {
x: area.x,
y,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().bg(row_bg)),
Span::styled(format!("{icon} "), Style::default().fg(t.yellow).bg(row_bg)),
Span::styled(name_padded, Style::default().fg(t.fg).bg(row_bg)),
Span::styled(
format!(" {age_str}"),
Style::default().fg(t.comment).bg(row_bg),
),
])),
row_rect,
);
app.rects.notes_panel_files.push((row_rect, path.clone()));
y += 1;
}
y += 1;
}
if y < area.y + area.height {
let new_rect = Rect {
x: area.x,
y,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
"+ New note",
Style::default()
.fg(t.green)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
])),
new_rect,
);
app.rects.notes_panel_new_chip = Some(new_rect);
}
}
pub fn notes_dir(workspace: &std::path::Path) -> std::path::PathBuf {
workspace.join(".mnml").join("notes")
}