use crate::app::{App, EditRow, Item, Overlay, View};
use crate::config::BorderStyle;
use crate::md::theme;
use crate::render::PCell;
use crate::tree::RowKind;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
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) {
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 => draw_palette(f, app),
Overlay::ConfirmDelete => draw_confirm(f, app),
Overlay::ConfirmCreate => draw_create(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.editor.scroll_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);
while top < crow {
let used: u16 = (top..crow)
.map(|r| row_height(app, &blocks, r, &dir, area.width).0)
.sum::<u16>()
+ cseg as u16
+ 1;
if used <= area.height {
break;
}
top += 1;
}
app.editor.scroll = top;
}
let top = app.editor.scroll.min(n.saturating_sub(1));
let mut plan: Vec<(usize, u16, Option<String>)> = 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 h = if natural == 0 {
0
} else {
natural.min(area.height - used).max(1)
};
plan.push((row, h, url));
used += h;
row += 1;
}
let mut lines: Vec<Line> = Vec::new();
let mut images: Vec<(Rect, String, bool)> = Vec::new();
app.edit_rows.clear();
let mut y = area.y;
for (row, h, url) 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(), false));
}
None => {
let segs = app.wrapped(*row, &blocks, width);
let selection = app.editor.selection_on(*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,
});
lines.push(seg.to_line(selection));
}
}
}
y += h;
}
f.render_widget(Paragraph::new(lines), area);
for (rect, url, clip_top) in images {
if let Some(protocol) = app.images.protocol(&url, &dir) {
f.render_stateful_widget(cropped(clip_top), rect, protocol);
}
}
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 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)
}
struct Row {
cells: Vec<PCell>,
checkbox: Option<usize>,
src_line: Option<usize>,
wide: bool,
}
fn draw_preview(f: &mut Frame, app: &mut App, area: Rect) {
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);
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<Row> = 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(Row {
cells: Vec::new(),
checkbox: None,
src_line: pline.src_line,
wide: false,
});
}
continue;
}
}
if pline.wide {
rows.push(Row {
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(Row {
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);
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;
app.preview_scroll = app.preview_scroll.min(max_scroll);
let top = app.preview_scroll as usize;
app.preview_links.clear();
app.preview_checkboxes.clear();
app.preview_rows.clear();
app.preview_page_rows.clear();
let span = app.preview_span();
let mut lines: Vec<Line> = Vec::new();
let mut images: Vec<(Rect, usize, bool)> = 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) {
images.push((
Rect::new(area.x, area.y + s.offset, area.width, s.rows),
*idx,
s.clip_top,
));
}
}
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, '›');
}
}
app.preview_page_rows.push((page_row, rect, offset));
lines.push(crate::render::to_line(&selected(
&shown, page_row, offset, span,
)));
app.preview_rows.push((rect, row.src_line, 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, &rendered) {
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);
for (rect, idx, clip_top) in images {
let url = rendered.images[idx].url.clone();
if let Some(protocol) = app.images.protocol(&url, &dir) {
f.render_stateful_widget(cropped(clip_top), rect, protocol);
}
}
}
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], rendered: &crate::render::Rendered) -> 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) = rendered.url(idx) {
runs.push((start, x - start, url.to_string()));
}
}
None => {
x += crate::md::char_width(cells[i].ch);
i += 1;
}
}
}
runs
}
fn wrap_cells(cells: &[PCell], width: usize) -> Vec<Vec<PCell>> {
crate::render::wrap_pcells(cells, width)
}
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());
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))),
dim(),
));
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.browse;
let items = palette_rows(app);
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 block = panel(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 returns to search"
} else if quick {
"open a note — any folder, most recent first"
} else {
"search notes or 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));
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 tab: search"
} else {
"tab: browse"
};
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,
}
fn palette_rows(app: &App) -> Vec<PRow> {
if app.overlay == Overlay::QuickOpen && app.browse {
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),
},
RowKind::Note {
entry,
name,
modified,
} => PRow {
name: format!("{}{name}", " ".repeat(r.depth + 1)),
detail: crate::index::age(modified, now),
tag: "",
item: Item::Entry(entry),
},
})
.collect();
}
app.overlay_items()
.into_iter()
.map(|item| {
let (name, detail, tag) = row_text(app, &item);
PRow {
item,
name,
detail,
tag,
}
})
.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::Note(idx) => {
let n = &app.notes[*idx];
(n.name(), folder_or_root(crate::index::folder_of(&n.path, &app.dir), app), "note")
}
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(_) => (String::new(), String::new(), ""),
}
}
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 truncate(text: &str, width: usize) -> String {
crate::md::truncate(text, width)
}
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_create(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 (name, dir) = app.pending_create().unwrap_or_else(|| {
(
app.pending_link.clone().unwrap_or_default(),
crate::index::short(&app.note_dir()),
)
});
let lines = vec![
Line::from(Span::styled(
format!(" create \u{201c}{name}\u{201d}?"),
theme::state().add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" this makes a new note in {dir}. enter to confirm, esc to cancel."),
dim(),
)),
];
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,
));
}
#[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);
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");
}
}
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::str_width(&c.to_string());
if w + cw + 1 > width {
break;
}
out.insert(0, c);
w += cw;
}
format!("…{out}")
}