use crate::app::{App, EditRow, Item, Overlay, View};
use crate::render::PCell;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use ratatui_image::{CropOptions, Resize, StatefulImage};
const DIM: Style = Style::new().fg(Color::DarkGray);
pub fn draw(f: &mut Frame, app: &mut App) {
let [content, status] =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(f.area());
let width = content.width.min(100);
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),
}
draw_status(f, app, status);
match app.overlay {
Overlay::Palette => draw_palette(f, app),
Overlay::ConfirmDelete => draw_confirm(f, app),
Overlay::RenameFile => draw_rename(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 = 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>) {
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>,
}
fn draw_preview(f: &mut Frame, app: &mut App, area: Rect) {
let rendered = crate::render::render(&app.active_note().content);
let width = area.width.max(1) as usize;
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,
});
}
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,
});
}
}
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();
let mut lines: Vec<Line> = Vec::new();
let mut images: Vec<(Rect, usize, bool)> = Vec::new();
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);
lines.push(crate::render::to_line(&row.cells));
if let Some(src) = row.src_line {
app.preview_rows.push((rect, src, row.cells.clone()));
if row.checkbox.is_some() {
app.preview_checkboxes.push((rect, src));
}
}
for (start, len, url) in link_runs(&row.cells, &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 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>> {
if width == 0 || crate::render::cells_width(cells) <= width {
return vec![cells.to_vec()];
}
let chars: Vec<char> = cells.iter().map(|c| c.ch).collect();
crate::md::wrap_breaks(&chars, width, width)
.into_iter()
.map(|(s, e)| cells[s..e].to_vec())
.collect()
}
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
let note = app.active_note();
let name = note
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let mode = match app.view {
View::Edit => "",
View::Preview => " preview",
};
let left = Line::from(vec![
Span::styled(format!(" {name}"), DIM),
Span::styled(mode, Style::new().fg(Color::Yellow)),
]);
let mut right_spans = Vec::new();
if let Some((msg, _)) = &app.status {
right_spans.push(Span::styled(
format!("{msg} "),
Style::new().fg(Color::Magenta),
));
}
right_spans.push(Span::styled(
format!(
"{} {} note{} ^K palette ^N new ^P preview ^C copy ^Q quit ",
if app.saved() { "saved" } else { "…" },
app.notes.len(),
if app.notes.len() == 1 { "" } else { "s" },
),
DIM,
));
let right = Line::from(right_spans);
f.render_widget(Paragraph::new(left), area);
f.render_widget(Paragraph::new(right).right_aligned(), area);
}
fn overlay_rect(f: &Frame, height: u16) -> Rect {
let area = f.area();
let width = area.width.saturating_sub(4).min(72);
let x = (area.width - width) / 2;
let y = (area.height / 5).max(1);
Rect::new(x, y, width, height.min(area.height.saturating_sub(y + 1)))
}
fn draw_palette(f: &mut Frame, app: &mut App) {
let items = app.palette_items();
let shown = items.len().min(10) as u16;
let rect = overlay_rect(f, shown + 3);
f.render_widget(Clear, rect);
let block = Block::default().borders(Borders::ALL).border_style(DIM);
let inner = block.inner(rect);
f.render_widget(block, rect);
let mut rows = vec![Constraint::Length(1); shown as usize + 1];
rows[0] = Constraint::Length(1);
let chunks = Layout::vertical(rows).split(inner);
let prompt = if app.query.is_empty() {
Line::from(Span::styled("search notes or run a command…", DIM))
} else {
Line::from(app.query.as_str())
};
f.render_widget(Paragraph::new(prompt), chunks[0]);
f.set_cursor_position((
chunks[0].x + crate::md::str_width(&app.query) as u16,
chunks[0].y,
));
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);
for (row_i, (i, item)) in items
.iter()
.enumerate()
.skip(start)
.take(shown as usize)
.enumerate()
{
let area = chunks[row_i + 1];
let selected = i == app.selected;
let (name, detail, tag) = match item {
Item::Command(c) => {
let (n, d) = c.label();
(n.to_string(), d.to_string(), "command")
}
Item::Note(idx) => {
let n = &app.notes[*idx];
let name = match n.detached_name() {
Some(name) => format!("{} ({name})", n.title()),
None => n.title(),
};
(name, n.snippet(), "")
}
};
let base = if selected {
Style::new().add_modifier(Modifier::REVERSED)
} else {
Style::new()
};
let line = Line::from(vec![
Span::styled(format!(" {name} "), base.add_modifier(Modifier::BOLD)),
Span::styled(detail.chars().take(40).collect::<String>(), base.patch(DIM)),
]);
f.render_widget(Paragraph::new(line).style(base), area);
if !tag.is_empty() {
f.render_widget(
Paragraph::new(Span::styled(format!("{tag} "), base.patch(DIM))).right_aligned(),
area,
);
}
app.palette_rows.push((area, item.clone()));
}
}
fn draw_confirm(f: &mut Frame, app: &mut App) {
let rect = overlay_rect(f, 4);
f.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Red));
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}”?"),
Style::new().fg(Color::Red).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 = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Yellow));
let inner = block.inner(rect);
f.render_widget(block, rect);
let lines = vec![
Line::from(vec![
Span::styled(" rename file ", Style::new().fg(Color::Yellow)),
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 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");
}
}