use crate::app::{App, EditRow, Item, Overlay, PageRow, PreviewPage, QuickTab, View};
use crate::config::BorderStyle;
use crate::md::truncate;
use crate::theme;
use crate::render::{wrap_pcells as wrap_cells, PCell};
use crate::tree::RowKind;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, StatefulWidget};
use ratatui::Frame;
use ratatui_image::{CropOptions, Resize, StatefulImage};
use std::time::SystemTime;
fn dim() -> Style {
theme::marker()
}
fn panel(app: &App) -> Block<'static> {
let block = Block::default().border_style(theme::border());
match app.config.borders {
BorderStyle::Rounded => block.borders(Borders::ALL).border_type(BorderType::Rounded),
BorderStyle::Square => block.borders(Borders::ALL).border_type(BorderType::Plain),
BorderStyle::None => block
.borders(Borders::NONE)
.padding(ratatui::widgets::Padding::new(1, 1, 0, 0)),
}
}
pub fn draw(f: &mut Frame, app: &mut App) {
if let Some(url) = app.zoom.clone() {
draw_zoom(f, app, &url);
return;
}
let status_h = if app.config.status_bar { 1 } else { 0 };
let [content, status] =
Layout::vertical([Constraint::Min(1), Constraint::Length(status_h)]).areas(f.area());
let width = match app.config.page_width {
0 => content.width,
w => content.width.min(w),
};
let [_, page, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(width),
Constraint::Fill(1),
])
.areas(content);
let page = page.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
app.editor_area = page;
match app.view {
View::Edit => draw_editor(f, app, page),
View::Preview => draw_preview(f, app, page),
}
if app.config.status_bar {
draw_status(f, app, status);
}
if app.overlay == Overlay::None {
draw_peek(f, app);
}
match app.overlay {
Overlay::Palette | Overlay::QuickOpen | Overlay::MoveFile => draw_palette(f, app),
Overlay::ConfirmDelete => draw_confirm(f, app),
Overlay::RenameFile => draw_rename(f, app),
Overlay::Help => draw_help(f, app),
Overlay::None => {}
}
}
fn draw_editor(f: &mut Frame, app: &mut App, area: Rect) {
let crow = app.editor.cursor.0;
let blocks = app.blocks();
let width = area.width.max(1) as usize;
let (cseg, cdisp) = app.cursor_seg(&blocks, width);
app.scroll_cursor_into_view(area.height as usize);
let dir = app.note_dir();
let n = app.editor.lines().len();
if app.editor.following() {
let mut top = app.editor.scroll.min(crow);
let heights: Vec<u32> = (top..crow)
.map(|r| row_height(app, &blocks, r, &dir, area.width).0 as u32)
.collect();
let mut used: u32 = heights.iter().sum::<u32>() + cseg as u32 + 1;
let mut i = 0;
while top < crow && used > area.height as u32 {
used -= heights[i];
i += 1;
top += 1;
}
app.editor.scroll = top;
}
let top = app.editor.scroll.min(n.saturating_sub(1));
let mut plan: Vec<(usize, u16, Option<String>, u16)> = Vec::new();
let mut used = 0u16;
let mut row = top;
while row < n && used < area.height {
let (natural, url) = row_height(app, &blocks, row, &dir, area.width);
let natural = match url {
Some(_) => crate::images::band_rows(natural, area.height),
None => natural,
};
let band = natural;
let h = if natural == 0 {
0
} else {
natural.min(area.height - used).max(1)
};
plan.push((row, h, url, band));
used += h;
row += 1;
}
let mut lines: Vec<Line> = Vec::new();
let mut images: Vec<(Rect, String, u16)> = Vec::new();
app.edit_rows.clear();
let mut y = area.y;
for (row, h, url, band) in &plan {
match url {
Some(url) => {
app.edit_rows.push(EditRow {
rect: Rect::new(area.x, y, area.width, *h),
line: *row,
seg: 0,
});
for _ in 0..*h {
lines.push(Line::default());
}
images.push((Rect::new(area.x, y, area.width, *h), url.clone(), *band));
}
None => {
let segs = app.wrapped(*row, &blocks, width);
let selection = app.editor.selection_on(*row);
let label = app.fold_label(*row);
for (i, seg) in segs.iter().enumerate().take(*h as usize) {
app.edit_rows.push(EditRow {
rect: Rect::new(area.x, y + i as u16, area.width, 1),
line: *row,
seg: i,
});
let mut line = seg.to_line(selection);
if i == 0 {
if let Some(label) = &label {
fold_note(&mut line, label, width);
}
}
lines.push(line);
}
}
}
y += h;
}
f.render_widget(Paragraph::new(lines), area);
for (rect, url, band) in images {
draw_band(f, app, &url, &dir, rect, band, 0);
}
if app.overlay == Overlay::None {
if let Some(band) = app
.edit_rows
.iter()
.find(|r| r.line == crow && r.seg == cseg)
.map(|r| r.rect)
{
let x = area.x + cdisp as u16;
if x < area.x + area.width {
f.set_cursor_position((x, band.y));
}
}
}
}
fn fold_note(line: &mut Line<'static>, label: &str, width: usize) {
let used = line.width();
let need = crate::md::str_width(label) + 2;
if used + need > width {
return;
}
line.spans
.push(Span::raw(" ".repeat(width - used - need + 2)));
line.spans.push(Span::styled(label.to_string(), dim()));
}
fn row_height(
app: &mut App,
blocks: &[crate::md::Block],
row: usize,
dir: &std::path::Path,
width: u16,
) -> (u16, Option<String>) {
if app.hidden_row(blocks, row) {
return (0, None);
}
let url = crate::md::block_at(blocks, row)
.filter(|b| b.kind == crate::md::BlockKind::Image && !app.revealed(b))
.and_then(|b| app.editor.lines().get(b.start))
.and_then(|src| crate::md::image_line(src))
.map(|(_, url)| url);
if let Some(url) = url {
if let Some(h) = app.images.rows(&url, dir, width) {
return (h, Some(url));
}
}
let rows = app.wrapped(row, blocks, width.max(1) as usize).len();
(rows.max(1) as u16, None)
}
fn preview_key(app: &mut App, area: Rect) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
let note = app.active_note();
note.path.hash(&mut h);
note.content.hash(&mut h);
area.width.hash(&mut h);
area.height.hash(&mut h);
app.config_gen.hash(&mut h);
app.folded_lines().hash(&mut h);
if app.config.linked_mentions && app.config.wikilinks {
app.linked_mentions().hash(&mut h);
}
let dir = app.note_dir();
for image in &app.preview_page.images {
app.images.resolve(&image.url, &dir).is_some().hash(&mut h);
}
h.finish()
}
fn layout_preview(app: &mut App, area: Rect) -> PreviewPage {
let width = area.width.max(1) as usize;
let content = &app.active_note().content;
let (skip, first) = crate::notes::front_matter_range(content)
.map_or((0, 0), |r| (r.end, content[..r.end].lines().count()));
let mut rendered =
crate::render::render_page_at(&content[skip..], first, width, app.config.table_style);
let folded = app.folded_lines();
crate::render::apply_folds(&mut rendered, &app.visible, &folded, width);
if app.config.linked_mentions && app.config.wikilinks {
let rows = app.linked_mentions();
crate::render::append_mentions(&mut rendered, &rows, width);
}
let dir = app.note_dir();
let mut rows: Vec<PageRow> = Vec::new();
let mut bands: Vec<(usize, u16, usize)> = Vec::new();
for pline in &rendered.lines {
if let Some(idx) = pline.image {
let url = rendered.images[idx].url.clone();
if let Some(natural) = app.images.rows(&url, &dir, area.width) {
let h = crate::images::band_rows(natural, area.height);
bands.push((rows.len(), h, idx));
for _ in 0..h {
rows.push(PageRow {
cells: Vec::new(),
checkbox: None,
src_line: pline.src_line,
wide: false,
});
}
continue;
}
}
if pline.wide {
rows.push(PageRow {
cells: pline.cells.clone(),
checkbox: pline.checkbox,
src_line: pline.src_line,
wide: true,
});
continue;
}
for (i, cells) in wrap_cells(&pline.cells, width).into_iter().enumerate() {
rows.push(PageRow {
cells,
checkbox: if i == 0 { pline.checkbox } else { None },
src_line: pline.src_line,
wide: false,
});
}
}
let widest = rows
.iter()
.filter(|r| r.wide)
.map(|r| crate::render::cells_width(&r.cells))
.max()
.unwrap_or(0);
PreviewPage {
key: None,
rows,
bands,
urls: rendered.urls,
images: rendered.images,
widest,
}
}
fn draw_preview(f: &mut Frame, app: &mut App, area: Rect) {
let width = area.width.max(1) as usize;
let key = preview_key(app, area);
if app.preview_page.key != Some(key) {
let mut page = layout_preview(app, area);
page.key = Some(key);
app.preview_page = page;
}
let page = std::mem::take(&mut app.preview_page);
let rows = &page.rows;
let bands = &page.bands;
let widest = page.widest;
app.preview_hmax = widest.saturating_sub(width) as u16;
app.preview_hscroll = app.preview_hscroll.min(app.preview_hmax);
let pan = app.preview_hscroll as usize;
let height = area.height as usize;
let max_scroll = rows.len().saturating_sub(height.max(1)) as u16;
if let Some(line) = app.preview_goto.take() {
if let Some(at) = rows
.iter()
.position(|r| r.src_line.is_some_and(|l| l >= line))
{
app.preview_scroll = at.saturating_sub(2) as u16;
}
}
app.preview_scroll = app.preview_scroll.min(max_scroll);
let top = app.preview_scroll as usize;
app.preview_links.clear();
app.preview_images.clear();
app.preview_image_urls = page.images.iter().map(|i| i.url.clone()).collect();
app.preview_checkboxes.clear();
app.preview_rows.clear();
let span = app.preview_span();
let mut lines: Vec<Line> = Vec::new();
let mut images: Vec<(Rect, usize, u16, u16)> = Vec::new();
let mut marked = false;
for (start, h, idx) in bands {
if let Some(s) = crate::images::band_slice(*start, *h, top, area.height) {
let hidden = if s.clip_top { h - s.rows } else { 0 };
images.push((
Rect::new(area.x, area.y + s.offset, area.width, s.rows),
*idx,
*h,
hidden,
));
}
}
for (i, row) in rows.iter().skip(top).take(height).enumerate() {
let y = area.y + i as u16;
let rect = Rect::new(area.x, y, area.width, 1);
let page_row = top + i;
let offset = if row.wide { pan } else { 0 };
let mut shown = if row.wide {
columns_from(&row.cells, pan, width)
} else {
row.cells.clone()
};
if row.wide && !marked {
marked = true;
if pan > 0 {
edge(&mut shown, 0, '‹');
}
if crate::render::cells_width(&row.cells) > pan + width && !shown.is_empty() {
let last = shown.len() - 1;
edge(&mut shown, last, '›');
}
}
lines.push(crate::render::to_line(&selected(
&shown, page_row, offset, span,
)));
app.preview_rows.push(crate::app::PreviewRow {
page_row,
rect,
pan: offset,
src_line: row.src_line,
cells: shown.clone(),
});
if row.checkbox.is_some() {
if let Some(src) = row.src_line {
app.preview_checkboxes.push((rect, src));
}
}
for (start, len, url) in link_runs(&shown, &page.urls) {
let x = area.x + start as u16;
app.preview_links
.push((Rect::new(x, y, len as u16, 1), url));
}
}
f.render_widget(Paragraph::new(lines), area);
let dir = app.note_dir();
for (rect, idx, band, hidden) in images {
let url = page.images[idx].url.clone();
if draw_band(f, app, &url, &dir, rect, band, hidden) {
app.preview_images.push((rect, url));
}
}
app.preview_page = page;
}
fn draw_band(
f: &mut Frame,
app: &mut App,
url: &str,
dir: &std::path::Path,
rect: Rect,
band: u16,
hidden: u16,
) -> bool {
let kitty = app.images.is_kitty();
let Some(protocol) = app.images.protocol(url, dir) else {
return false;
};
if !kitty || band == 0 {
f.render_stateful_widget(cropped(hidden > 0), rect, protocol);
return true;
}
let whole = Rect::new(rect.x, 0, rect.width, band);
let mut scratch = Buffer::empty(whole);
StatefulImage::default()
.resize(Resize::Crop(None))
.render(whole, &mut scratch, protocol);
let carry = if hidden > 0 {
let sym = scratch[(whole.x, 0)].symbol();
sym.rfind("\x1b[s")
.filter(|&i| i > 0)
.map(|i| sym[..i].to_string())
} else {
None
};
let buf = f.buffer_mut();
for r in 0..rect.height {
let src_y = hidden + r;
if src_y >= band {
break;
}
for x in 0..rect.width {
let cell = scratch[(whole.x + x, src_y)].clone();
if let Some(dst) = buf.cell_mut((rect.x + x, rect.y + r)) {
*dst = cell;
}
}
}
if let Some(seq) = carry {
if let Some(dst) = buf.cell_mut((rect.x, rect.y)) {
let sym = format!("{seq}{}", dst.symbol());
dst.set_symbol(&sym);
}
}
true
}
fn draw_zoom(f: &mut Frame, app: &mut App, url: &str) {
let whole = f.area();
f.render_widget(Clear, whole);
let [content, status] =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(whole);
let dir = app.note_dir();
let name = std::path::Path::new(url)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| url.to_string());
let left = match app.images.zoom(url, &dir, (content.width, content.height)) {
Some(((w, h), natural, protocol)) => {
let rect = Rect::new(
content.x + (content.width.saturating_sub(w)) / 2,
content.y + (content.height.saturating_sub(h)) / 2,
w.min(content.width),
h.min(content.height),
);
f.render_stateful_widget(cropped(false), rect, protocol);
format!("{name} · {}×{}", natural.0, natural.1)
}
None => {
let msg = format!("🖼 {url}");
let y = content.y + content.height / 2;
let x = content.x
+ content
.width
.saturating_sub(crate::md::str_width(&msg) as u16)
/ 2;
let rect = Rect::new(x, y, content.width.saturating_sub(x - content.x), 1);
f.render_widget(Paragraph::new(Line::from(Span::styled(msg, dim()))), rect);
name
}
};
let count = app.preview_image_urls.len();
let right = if count > 1 {
let at = app
.preview_image_urls
.iter()
.position(|u| u == url)
.map_or(0, |i| i + 1);
format!("{at} of {count} · ← → next · esc close")
} else {
"esc close".to_string()
};
let gap = status
.width
.saturating_sub((crate::md::str_width(&left) + crate::md::str_width(&right)) as u16 + 2);
let line = Line::from(vec![
Span::styled(format!(" {left}"), dim()),
Span::raw(" ".repeat(gap as usize)),
Span::styled(format!("{right} "), dim()),
]);
f.render_widget(Paragraph::new(line), status);
}
fn edge(cells: &mut [PCell], at: usize, ch: char) {
if let Some(c) = cells.get_mut(at) {
c.ch = ch;
c.style = theme::state();
c.link = None;
}
}
fn columns_from(cells: &[PCell], from: usize, width: usize) -> Vec<PCell> {
let mut out = Vec::new();
let mut col = 0;
for c in cells {
let w = crate::md::char_width(c.ch);
if col >= from && col + w <= from + width {
out.push(c.clone());
}
col += w;
}
out
}
fn selected(
cells: &[PCell],
page_row: usize,
offset: usize,
span: Option<(crate::app::PSel, crate::app::PSel)>,
) -> Vec<PCell> {
let Some(((sr, sc), (er, ec))) = span else {
return cells.to_vec();
};
if page_row < sr || page_row > er {
return cells.to_vec();
}
let from = if page_row == sr { sc } else { 0 };
let to = if page_row == er { ec } else { usize::MAX };
let mut out = Vec::with_capacity(cells.len());
let mut col = offset;
for c in cells {
let mut c = c.clone();
if col >= from && col < to {
c.style = c.style.add_modifier(Modifier::REVERSED);
}
col += crate::md::char_width(c.ch);
out.push(c);
}
out
}
fn cropped(clip_top: bool) -> StatefulImage<ratatui_image::protocol::StatefulProtocol> {
StatefulImage::default().resize(Resize::Crop(Some(CropOptions {
clip_top,
clip_left: false,
})))
}
fn link_runs(cells: &[PCell], urls: &[String]) -> Vec<(usize, usize, String)> {
let mut runs = Vec::new();
let mut i = 0;
let mut x = 0; while i < cells.len() {
match cells[i].link {
Some(idx) => {
let start = x;
while i < cells.len() && cells[i].link == Some(idx) {
x += crate::md::char_width(cells[i].ch);
i += 1;
}
if let Some(url) = urls.get(idx) {
runs.push((start, x - start, url.clone()));
}
}
None => {
x += crate::md::char_width(cells[i].ch);
i += 1;
}
}
}
runs
}
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
use crate::config::StatusItem;
let note = app.active_note();
let path = crate::index::short(¬e.path);
let name = note
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let mode = match app.view {
View::Edit => "edit",
View::Preview => "preview",
};
let status = app
.status
.as_ref()
.map(|(m, _)| m.clone())
.or_else(|| app.cursor_link_hint());
let items = &app.config.status_bar_items;
let shows = |i: StatusItem| items.contains(&i);
let mut left_text = String::new();
for item in items {
let piece = match item {
StatusItem::Path => path.clone(),
StatusItem::Name => name.clone(),
StatusItem::Mode => mode.to_string(),
StatusItem::Properties => match properties_label(¬e.content) {
Some(s) => s,
None => continue,
},
_ => continue,
};
if piece.is_empty() {
continue;
}
if !left_text.is_empty() {
left_text.push_str(" ");
}
left_text.push_str(&piece);
}
let status = status.filter(|_| shows(StatusItem::Message));
let hints = if shows(StatusItem::Keys) && app.config.key_hints {
hint_pairs(app)
} else {
Vec::new()
};
let total = area.width as usize;
let left_w = if left_text.is_empty() {
0
} else {
crate::md::str_width(&left_text) + 1
};
let status_w = status
.as_ref()
.map(|m| crate::md::str_width(m) + 3)
.unwrap_or(0);
let room = total.saturating_sub(left_w + status_w + 3);
let right = fit_hints(&hints, room);
let left = Line::from(Span::styled(
format!(" {}", truncate_left(&left_text, total.saturating_sub(8))),
theme::grey(),
));
let mut right_spans = Vec::new();
if let Some(msg) = status {
right_spans.push(Span::styled(format!("{msg} "), theme::state()));
}
if !right.is_empty() {
right_spans.push(Span::styled(format!("{right} "), dim()));
}
f.render_widget(Paragraph::new(left), area);
f.render_widget(
Paragraph::new(Line::from(right_spans)).right_aligned(),
area,
);
}
fn properties_label(content: &str) -> Option<String> {
crate::notes::front_matter_range(content)?;
Some(match crate::notes::property_count(content) {
1 => "1 property".to_string(),
n => format!("{n} properties"),
})
}
fn hint_pairs(app: &App) -> Vec<(String, &'static str)> {
use crate::keys::Action;
let keys = &app.config.keys;
let mut pairs: Vec<(String, &'static str)> = Vec::new();
if app.view == View::Preview && app.preview_hmax > 0 {
pairs.push(("← →".to_string(), "table"));
}
for (action, what) in [(Action::Help, "help"), (Action::Quit, "quit")] {
let key = keys.label(action);
if !key.is_empty() {
pairs.push((key, what));
}
}
pairs
}
fn fit_hints(hints: &[(String, &'static str)], room: usize) -> String {
let worded = hints
.iter()
.map(|(k, w)| format!("{k} {w}"))
.collect::<Vec<_>>()
.join(" ");
if crate::md::str_width(&worded) <= room {
return worded;
}
let mut keys: Vec<&str> = hints.iter().map(|(k, _)| k.as_str()).collect();
while !keys.is_empty() {
let line = keys.join(" ");
if crate::md::str_width(&line) <= room {
return line;
}
keys.pop();
}
String::new()
}
fn overlay_rect(f: &Frame, height: u16) -> Rect {
overlay_rect_wide(f, height, 72)
}
fn overlay_rect_wide(f: &Frame, height: u16, max: u16) -> Rect {
let area = f.area();
let width = area.width.saturating_sub(4).min(max);
let x = (area.width - width) / 2;
Rect::new(x, overlay_top(f), width, height.min(overlay_budget(f)))
}
fn overlay_top(f: &Frame) -> u16 {
(f.area().height / 5).max(1)
}
fn overlay_budget(f: &Frame) -> u16 {
f.area().height.saturating_sub(overlay_top(f) + 1)
}
fn draw_palette(f: &mut Frame, app: &mut App) {
let quick = app.overlay == Overlay::QuickOpen;
let browse = quick && app.tab == QuickTab::Tree;
let contents = quick && app.tab == QuickTab::Contents;
let width = overlay_rect_wide(f, 1, 100).width.saturating_sub(2) as usize;
let items = palette_rows(app, width);
let chrome = if quick { 5 } else { 4 };
let room = overlay_budget(f).saturating_sub(chrome).clamp(1, 16) as usize;
let shown = items.len().min(room) as u16;
let rect = overlay_rect_wide(f, shown + chrome, 100);
app.overlay_rect = rect;
f.render_widget(Clear, rect);
let mut block = panel(app);
if let Some((tag, _)) = app.tag_filter.as_ref().filter(|_| quick) {
block = block.title(Span::styled(format!(" #{tag} "), theme::state()));
} else if quick {
block = block.title(tab_strip(app));
}
let inner = block.inner(rect);
f.render_widget(block, rect);
let rows = vec![Constraint::Length(1); shown as usize + 2 + usize::from(quick)];
let chunks = Layout::vertical(rows).split(inner);
let caret = Span::styled(" ❯ ", theme::bright());
let prompt = if app.query.is_empty() {
let hint = if browse {
"browse folders — tab for contents"
} else if contents {
"type to search note contents"
} else if quick && app.tag_filter.is_some() {
"notes carrying this tag — type to narrow"
} else if quick {
"open a note — any folder, most recent first"
} else if app.overlay == Overlay::MoveFile {
"move this note to a folder"
} else {
"run a command"
};
Line::from(vec![caret, Span::styled(hint, dim())])
} else {
Line::from(vec![
caret,
Span::styled(
app.query.as_str(),
Style::new().add_modifier(Modifier::BOLD),
),
])
};
f.render_widget(Paragraph::new(prompt), chunks[0]);
f.set_cursor_position((
chunks[0].x + 3 + crate::md::str_width(&app.query) as u16,
chunks[0].y,
));
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"─".repeat(inner.width.saturating_sub(2) as usize),
theme::border(),
)))
.style(theme::border()),
chunks[1].inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 0,
}),
);
app.selected = app.selected.min(items.len().saturating_sub(1));
app.palette_rows.clear();
let start = app
.selected
.saturating_sub(shown.saturating_sub(1) as usize);
let keyw = items
.iter()
.filter_map(|r| match &r.item {
Item::Command(c) => c.action().map(|a| app.config.keys.label(a)),
_ => None,
})
.map(|k| crate::md::str_width(&k))
.max()
.unwrap_or(0);
let longest_name = items
.iter()
.skip(start)
.take(shown as usize)
.map(|r| crate::md::str_width(&r.name))
.max()
.unwrap_or(0)
.clamp(22, 40);
for (row_i, (i, row)) in items
.iter()
.enumerate()
.skip(start)
.take(shown as usize)
.enumerate()
{
let area = chunks[row_i + 2];
let selected = i == app.selected;
let (name, detail, tag) = (&row.name, &row.detail, row.tag);
let item = &row.item;
let tag = if tag == "note" { "" } else { tag };
let key = match item {
Item::Command(c) => c
.action()
.map(|a| app.config.keys.label(a))
.unwrap_or_default(),
_ => String::new(),
};
let row_bg = if selected { theme::row() } else { Style::new() };
let title = if selected {
row_bg
.fg(theme::palette().bright)
.add_modifier(Modifier::BOLD)
} else {
row_bg
};
let namew = (area.width as usize)
.saturating_sub(6 + keyw + 3 + tag_width(tag))
.clamp(8, if browse { 40 } else { longest_name });
let padded = pad_to(name, namew);
let detailw =
(area.width as usize).saturating_sub(4 + namew + 2 + keyw + 3 + tag_width(tag));
if let Some(line) = &row.line {
let line = line.clone().patch_style(row_bg);
f.render_widget(Paragraph::new(line).style(row_bg), area);
app.palette_rows.push((area, item.clone()));
continue;
}
let mut spans = vec![
Span::styled(" ", row_bg),
Span::styled(padded, title),
Span::styled(" ", row_bg),
Span::styled(truncate(detail, detailw), row_bg.patch(dim())),
];
if !tag.is_empty() {
spans.push(Span::styled(format!(" · {tag}"), row_bg.patch(dim())));
}
f.render_widget(Paragraph::new(Line::from(spans)).style(row_bg), area);
if !key.is_empty() {
let style = if selected {
row_bg.fg(theme::palette().bright)
} else {
dim()
};
f.render_widget(
Paragraph::new(Span::styled(format!("{key} "), style))
.style(row_bg)
.right_aligned(),
area,
);
}
app.palette_rows.push((area, item.clone()));
}
if quick {
let hint = if browse {
"←→ fold ↵ open ⌥↵ split tab: contents"
} else if contents {
"↵ open at the line ⌥↵ split tab: recent"
} else {
"↵ open ⌥↵ split ⌘↵ tab tab: tree"
};
f.render_widget(
Paragraph::new(Span::styled(format!(" {hint}"), dim())),
chunks[chunks.len() - 1],
);
}
}
struct PRow {
item: Item,
name: String,
detail: String,
tag: &'static str,
line: Option<Line<'static>>,
}
fn tab_strip(app: &App) -> Line<'static> {
let on = app.tab as usize;
let mut spans = vec![Span::raw(" ")];
for (i, name) in ["recent", "tree", "contents"].into_iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" · ", dim()));
}
let style = if i == on { theme::state() } else { dim() };
spans.push(Span::styled(name, style));
}
spans.push(Span::raw(" "));
Line::from(spans)
}
fn contents_rows(app: &App, width: usize) -> Vec<PRow> {
use crate::contents::Row;
let words = crate::contents::words(&app.query);
let plain = |item: Item, line: Line<'static>| PRow {
item,
name: String::new(),
detail: String::new(),
tag: "",
line: Some(line),
};
app.contents_rows()
.into_iter()
.map(|r| match r {
Row::Note(entry) => {
let (name, folder) = app
.open_index
.get(entry)
.map(|e| (e.name(), folder_or_root(e.folder.clone(), app)))
.unwrap_or_default();
let text = truncate(&format!(" {name} {folder}"), width);
plain(Item::Entry(entry), Line::from(Span::styled(text, dim())))
}
Row::Hit(h) => {
let mut spans = vec![Span::raw(" ")];
for (piece, lit) in
crate::contents::snippet(&h.text, &words, width.saturating_sub(4))
{
let style = if lit { theme::state() } else { Style::new() };
spans.push(Span::styled(piece, style));
}
plain(Item::Line(h.entry, h.line), Line::from(spans))
}
Row::More(n) => plain(
Item::Notice,
Line::from(Span::styled(format!(" …and {n} more"), dim())),
),
})
.collect()
}
fn palette_rows(app: &App, width: usize) -> Vec<PRow> {
if app.overlay == Overlay::QuickOpen && app.tab == QuickTab::Contents {
return contents_rows(app, width);
}
if app.overlay == Overlay::QuickOpen && app.tab == QuickTab::Tree {
let now = SystemTime::now();
return app
.browse_rows()
.into_iter()
.map(|r| match r.kind {
RowKind::Folder {
key,
name,
notes,
open,
} => PRow {
name: format!(
"{}{} {name}",
" ".repeat(r.depth),
if open { '▾' } else { '▸' }
),
detail: match (open, notes) {
(true, _) => String::new(),
(false, 1) => "1 note".to_string(),
(false, n) => format!("{n} notes"),
},
tag: "",
item: Item::Folder(key),
line: None,
},
RowKind::Note {
entry,
name,
modified,
} => PRow {
name: format!("{}{name}", " ".repeat(r.depth + 1)),
detail: crate::index::age(modified, now),
tag: "",
item: Item::Entry(entry),
line: None,
},
})
.collect();
}
app.overlay_items()
.into_iter()
.map(|item| {
let (name, detail, tag) = row_text(app, &item);
PRow {
item,
name,
detail,
tag,
line: None,
}
})
.collect()
}
fn row_text(app: &App, item: &Item) -> (String, String, &'static str) {
match item {
Item::Command(c) => {
let (n, d) = c.label();
(n.to_string(), d.to_string(), "")
}
Item::Path(path) => (
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
crate::index::short(path.parent().unwrap_or(path)),
"path",
),
Item::Entry(idx) => match app.open_index.get(*idx) {
Some(e) => (e.name(), folder_or_root(e.folder.clone(), app), "note"),
None => (String::new(), String::new(), ""),
},
Item::Folder(_) | Item::Line(..) | Item::Notice => (String::new(), String::new(), ""),
Item::MoveTo(dir) => {
let notes = std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.count()
})
.unwrap_or(0);
let detail = match notes {
0 => "empty".to_string(),
1 => "1 note".to_string(),
n => format!("{n} notes"),
};
(app.move_label(dir), detail, "")
}
}
}
fn folder_or_root(folder: String, app: &App) -> String {
if !folder.is_empty() {
return folder;
}
app.dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn tag_width(tag: &str) -> usize {
if tag.is_empty() {
0
} else {
crate::md::str_width(tag) + 3
}
}
fn pad_to(text: &str, width: usize) -> String {
let cut = truncate(text, width);
let pad = width.saturating_sub(crate::md::str_width(&cut));
format!("{cut}{}", " ".repeat(pad))
}
fn draw_peek(f: &mut Frame, app: &mut App) {
let table_style = app.config.table_style;
let block = panel(app);
let Some(peek) = app.peek.as_mut() else {
return;
};
let screen = f.area();
let width = 80
.min(screen.width.saturating_sub(4))
.max(10)
.min(screen.width);
let inner_w = width.saturating_sub(2) as usize;
peek.ensure_rendered(inner_w, table_style);
let cap = (screen.height * crate::app::PEEK_MAX_HEIGHT_PCT / 100).max(5);
let total = peek.rows.len().max(1);
let height = ((total as u16).saturating_add(2))
.min(cap)
.min(screen.height);
peek.view_rows = height.saturating_sub(2) as usize;
peek.clamp();
let mut lines: Vec<Line> = peek
.rows
.iter()
.skip(peek.scroll)
.take(peek.view_rows)
.cloned()
.collect();
if peek.rows.is_empty() {
lines.push(Line::from(Span::styled("(empty note)", dim())));
}
let a = peek.anchor;
let y = if a.y + 1 + height <= screen.height {
a.y + 1
} else {
a.y.saturating_sub(height)
};
let x = a.x.min(screen.width.saturating_sub(width));
let rect = Rect::new(x, y, width, height);
peek.rect = rect;
f.render_widget(Clear, rect);
let title = format!(" {} ", truncate(&peek.name, inner_w.saturating_sub(2)));
let mut block = block.title(Span::styled(title, theme::state()));
let pos = (peek.rows.len() > peek.view_rows).then(|| {
let first = peek.scroll + 1;
let last = (peek.scroll + peek.view_rows).min(peek.rows.len());
format!(" {first}–{last} of {} ", peek.rows.len())
});
let hint = " click to open · ↑↓ scroll ";
let used = pos.as_ref().map_or(0, |p| p.chars().count());
if hint.chars().count() + used <= inner_w {
block = block.title_bottom(Line::from(Span::styled(hint, dim())));
}
if let Some(pos) = pos {
block = block.title_bottom(Line::from(Span::styled(pos, dim())).right_aligned());
}
let inner = block.inner(rect);
f.render_widget(block, rect);
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_help(f: &mut Frame, app: &App) {
let bound = app.config.keys.card_rows();
let groups: Vec<(&str, Vec<(String, &str)>)> = std::iter::once(("keys", bound))
.chain(crate::app::SHORTCUTS.iter().map(|(g, rows)| {
(
*g,
rows.iter()
.map(|(k, w)| (k.to_string(), *w))
.collect::<Vec<_>>(),
)
}))
.collect();
let keyw = groups
.iter()
.flat_map(|(_, rows)| rows.iter())
.map(|(k, _)| crate::md::str_width(k))
.max()
.unwrap_or(0);
let q = app.help_query.trim();
let groups: Vec<(&str, Vec<(String, &str)>)> = groups
.into_iter()
.map(|(group, rows)| {
let rows = rows
.into_iter()
.filter(|(k, w)| crate::search::fuzzy(q, &format!("{group} {k} {w}")).is_some())
.collect::<Vec<_>>();
(group, rows)
})
.filter(|(_, rows)| !rows.is_empty())
.collect();
let mut lines: Vec<Line> = Vec::new();
for (i, (group, rows)) in groups.iter().enumerate() {
if i > 0 {
lines.push(Line::default());
}
lines.push(Line::from(Span::styled(
format!(" {group}"),
theme::heading(2),
)));
for (key, what) in rows.iter() {
let pad = " ".repeat(keyw.saturating_sub(crate::md::str_width(key)));
lines.push(Line::from(vec![
Span::styled(format!(" {pad}{key} "), theme::bright()),
Span::styled(what.to_string(), dim()),
]));
}
}
if groups.is_empty() {
lines.push(Line::from(Span::styled(" nothing matches", dim())));
}
lines.push(Line::default());
lines.push(if q.is_empty() {
Line::from(Span::styled(" type to search · esc closes", dim()))
} else {
Line::from(vec![
Span::styled(" search ", dim()),
Span::styled(app.help_query.clone(), theme::state()),
Span::styled(" · esc closes", dim()),
])
});
let area = f.area();
let width = (keyw as u16 + 54).min(area.width.saturating_sub(2));
let height = (lines.len() as u16 + 2).min(area.height);
let rect = Rect::new(
(area.width.saturating_sub(width)) / 2,
(area.height.saturating_sub(height)) / 2,
width,
height,
);
f.render_widget(Clear, rect);
let block = panel(app).title(Span::styled(" help ", theme::state()));
let inner = block.inner(rect);
f.render_widget(block, rect);
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_confirm(f: &mut Frame, app: &mut App) {
let rect = overlay_rect(f, 4);
f.render_widget(Clear, rect);
let block = panel(app).border_style(theme::danger());
let inner = block.inner(rect);
f.render_widget(block, rect);
let title = app.active_note().title();
let lines = vec![
Line::from(Span::styled(
format!(" delete “{title}”?"),
theme::danger().add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
" this deletes the file. enter to confirm, esc to cancel.",
dim(),
)),
];
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_rename(f: &mut Frame, app: &mut App) {
let rect = overlay_rect(f, 4);
f.render_widget(Clear, rect);
let block = panel(app);
let inner = block.inner(rect);
f.render_widget(block, rect);
let lines = vec![
Line::from(vec![
Span::styled(" rename file ", theme::state()),
Span::styled(
app.rename_input.as_str(),
Style::new().add_modifier(Modifier::BOLD),
),
Span::styled(".md", dim()),
]),
Line::from(Span::styled(
" enter renames the file on disk, esc cancels.",
dim(),
)),
];
f.render_widget(Paragraph::new(lines), inner);
f.set_cursor_position((
inner.x + 14 + crate::md::str_width(&app.rename_input) as u16,
inner.y,
));
}
fn truncate_left(text: &str, width: usize) -> String {
if crate::md::str_width(text) <= width {
return text.to_string();
}
let mut out = String::new();
let mut w = 0;
for c in text.chars().rev() {
let cw = crate::md::char_width(c);
if w + cw + 1 > width {
break;
}
out.insert(0, c);
w += cw;
}
format!("…{out}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::render;
fn cells(text: &str) -> Vec<PCell> {
text.chars()
.map(|ch| PCell {
ch,
style: Style::default(),
link: None,
src: None,
})
.collect()
}
#[test]
fn wrapping_breaks_on_spaces_and_keeps_every_word() {
let rows = wrap_cells(&cells("the quick brown fox"), 10);
let text: Vec<String> = rows
.iter()
.map(|r| r.iter().map(|c| c.ch).collect())
.collect();
assert_eq!(text, vec!["the quick", "brown fox"]);
assert!(rows.iter().all(|r| r.len() <= 10));
}
#[test]
fn wrapping_a_short_line_leaves_it_alone() {
let rows = wrap_cells(&cells("short"), 10);
assert_eq!(rows.len(), 1);
}
#[test]
fn wrapping_counts_display_columns_not_characters() {
let rows = wrap_cells(&cells("漢字漢字漢字漢字"), 10);
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|r| crate::render::cells_width(r) <= 10));
}
#[test]
fn the_status_bar_counts_properties_only_when_there_are_some_to_count() {
assert_eq!(
properties_label("---\ntype: log\ntags: work\n---\n# t\n").as_deref(),
Some("2 properties")
);
assert_eq!(
properties_label("---\ntype: log\n---\n").as_deref(),
Some("1 property")
);
assert_eq!(
properties_label("---\n---\nbody\n").as_deref(),
Some("0 properties")
);
assert_eq!(properties_label("# Title\n\nbody\n"), None);
assert_eq!(properties_label("---\nunterminated: yes\n"), None);
}
#[test]
fn link_runs_cover_the_link_text_only() {
let r = render("see [docs](http://x.y) now");
let line = &r.lines[0];
let runs = link_runs(&line.cells, &r.urls);
assert_eq!(runs.len(), 1);
let (start, len, url) = &runs[0];
assert_eq!(*start, 4);
assert_eq!(*len, 4);
assert_eq!(url, "http://x.y");
}
}