use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Clear, Paragraph};
use crate::app::{App, EditFocus, MenuAction};
use crate::note::{Note, THEMES, Theme, color_name, parse_hex, parse_md};
pub fn render(app: &App, frame: &mut Frame) {
let area = frame.area();
let w = area.width;
let h = area.height;
if w == 0 || h == 0 {
return;
}
frame.render_widget(Clear, area);
let theme = &THEMES[app.theme_idx];
if app.show_overlay && app.count() > 0 && app.selected < app.count() {
render_overlay(app, frame, theme);
return;
}
if app.count() == 0 && !app.show_help {
render_welcome(frame, area, theme);
render_footer(app, frame, theme, h);
return;
}
let footer_h = 2u16;
let inset_w = w.saturating_sub(2);
let tab_h = 1u16;
render_tabs(app, frame, theme, inset_w);
let content_h = h.saturating_sub(tab_h + footer_h);
if content_h > 0 && app.count() > 0 {
let note = &app.notes[app.selected];
let header_focused = note.editing && app.edit_focus == EditFocus::Header;
let tag_focused = note.editing && app.edit_focus == EditFocus::Tags;
let tag_suggestions: Vec<&str> = if tag_focused && !note.tag_input.is_empty() {
let input = note.tag_input.to_lowercase();
app.all_tags
.iter()
.filter(|t| t.to_lowercase().contains(&input))
.take(5)
.map(|s| s.as_str())
.collect()
} else {
Vec::new()
};
render_full_note(
note,
theme,
header_focused,
tag_focused,
&tag_suggestions,
Rect::new(1, tab_h, inset_w, content_h),
frame,
);
}
if app.menu.visible {
render_menu(app, frame, theme);
}
if app.show_help {
render_help(frame, area, theme);
}
render_footer(app, frame, theme, h);
}
fn tab_label(note: &Note, remaining: u16) -> String {
let raw: String = if !note.title.is_empty() {
note.title.clone()
} else {
let first = note.first_line();
if first.is_empty() {
"empty".to_string()
} else {
crate::note::strip_md(first)
}
};
let marker = if !note.tags.is_empty() { "#" } else { "●" };
let label = format!(" {} {} ", marker, raw);
if label.chars().count() as u16 > remaining {
let max_title = remaining.saturating_sub(4) as usize;
let truncated: String = raw.chars().take(max_title.max(1)).collect();
format!(" {}… ", truncated)
} else {
label
}
}
fn render_tabs(app: &App, frame: &mut Frame, theme: &Theme, inset_w: u16) {
let visible_indices = app.visible_note_indices();
let mut x = 1u16;
for (i, ¬e_idx) in visible_indices.iter().enumerate() {
if x >= inset_w {
break;
}
let note = &app.notes[note_idx];
let remaining = inset_w.saturating_sub(x);
let label = tab_label(note, remaining);
let w = label.len() as u16;
let is_selected = note_idx == app.selected;
let note_color = parse_hex(¬e.color);
let style = if is_selected {
Style::new()
.bg(theme.sel_border)
.fg(Color::Rgb(0x1a, 0x1a, 0x1a))
} else {
Style::new().fg(note_color)
};
render_par(frame, &label, style, Rect::new(x, 0, w, 1));
x += w;
if i < visible_indices.len() - 1 && x < inset_w {
render_par(frame, " ", Style::new(), Rect::new(x, 0, 1, 1));
x += 1;
}
}
let rendered = visible_indices.len();
let total = app.notes.len();
if rendered < total && x < inset_w {
let more = format!(" +{}", total - rendered);
render_par(
frame,
&more,
Style::new().fg(Color::Rgb(0x88, 0x88, 0x88)),
Rect::new(x, 0, more.len() as u16, 1),
);
}
}
pub fn note_index_at_tab_x(app: &App, mx: u16) -> Option<usize> {
let inset_w = app.width.saturating_sub(2);
let mut x = 1u16;
let visible = app.visible_note_indices();
for ¬e_idx in &visible {
if x >= inset_w {
break;
}
let note = &app.notes[note_idx];
let remaining = inset_w.saturating_sub(x);
let label = tab_label(note, remaining);
let w = label.len() as u16;
if mx >= x && mx < x + w {
return Some(note_idx);
}
x += w + 1; }
None
}
#[allow(clippy::too_many_arguments)]
fn render_full_note(
note: &Note,
theme: &Theme,
header_focused: bool,
tag_focused: bool,
tag_suggestions: &[&str],
rect: Rect,
frame: &mut Frame,
) {
let bg = parse_hex(¬e.color);
let fg = Color::Rgb(0x1a, 0x1a, 0x1a);
let base_style = Style::new().fg(fg);
let dim_style = Style::new().fg(fg).dim();
let border_type = match note.border_style.as_str() {
"double" => BorderType::Double,
"thick" => BorderType::Thick,
"hidden" | "none" => BorderType::Plain,
_ => BorderType::Rounded,
};
let border_fg = if note.editing {
Color::Rgb(0, 0, 0)
} else if note.border_style == "hidden" && !note.editing {
bg
} else {
fg
};
let has_border = note.border_style != "hidden" || note.editing;
let (inner_x, inner_y, inner_w, inner_h) = if has_border {
(
rect.x + 1,
rect.y + 1,
rect.width.saturating_sub(2),
rect.height.saturating_sub(2),
)
} else {
(rect.x, rect.y, rect.width, rect.height)
};
let max_line = inner_w.saturating_sub(2);
let title_width = if header_focused {
inner_w.saturating_sub(2)
} else {
inner_w
};
let has_title = !note.title.is_empty() || header_focused;
let header_display = if has_title {
let title_with_cursor = if header_focused {
let mut t = note.title.clone();
t.insert(note.title_cursor, '█');
t
} else {
note.title.clone()
};
let title_len = title_with_cursor.len() as u16;
if title_len <= title_width {
let pad = (title_width - title_len) / 2;
format!("{:pad$}{}", "", title_with_cursor, pad = pad as usize)
} else {
let max = title_width as usize;
if header_focused && note.title_cursor >= max {
let offset = note.title_cursor.saturating_sub(max.saturating_sub(1));
let tail: String = note
.title
.chars()
.skip(offset)
.take(max.saturating_sub(1))
.collect();
format!("{}█", tail)
} else {
let max_show = max.saturating_sub(1);
format!("{}…", &title_with_cursor[..max_show])
}
}
} else {
let info = format!(" ● {} [{}]", color_name(¬e.color), note.border_style);
if info.len() as u16 > title_width {
let max = title_width.saturating_sub(1) as usize;
format!("{}…", &info[..max])
} else {
let pad = (inner_w - info.len() as u16) / 2;
format!("{:pad$}{}", "", info, pad = pad as usize)
}
};
let header_line: Line = if note.has_title_selection() && header_focused {
render_title_with_selection(note, title_width, base_style, theme)
} else {
let hdr_style = if header_focused {
Style::new().fg(theme.sel_border)
} else {
Style::new().fg(fg).dim()
};
Line::from(Span::styled(header_display, hdr_style))
};
let has_tags_area =
!note.tags.is_empty() || note.editing && (tag_focused || !note.tag_input.is_empty());
let bg_style = Style::new().bg(bg);
let focus_sep = Style::new().fg(Color::Rgb(0, 0, 0));
let content_focused = note.editing && !header_focused && !tag_focused;
let sep_header = if content_focused {
focus_sep
} else {
dim_style
};
let sep_tags = if tag_focused { focus_sep } else { dim_style };
let header_sep_line = Span::styled("─".repeat(inner_w as usize), sep_header);
let tags_sep_line = Span::styled("─".repeat(inner_w as usize), sep_tags);
let mut next_y = inner_y;
let header_rect = Rect::new(inner_x, next_y, inner_w, 1);
next_y += 1;
let header_sep_y = next_y;
next_y += 1;
let tags_section_h = if has_tags_area && inner_y + inner_h > header_sep_y + 1 {
2u16
} else {
0u16
};
let body_h = (inner_y + inner_h - next_y).saturating_sub(tags_section_h);
let body_rect = Rect::new(inner_x, next_y, inner_w, body_h);
next_y += body_h;
let tags_sep_y = next_y;
next_y += 1;
let tags_rect = Rect::new(inner_x, next_y, inner_w, 1);
let focus_border = Style::new().fg(Color::Rgb(0, 0, 0));
let focus_block = || {
Block::bordered()
.border_type(BorderType::Thick)
.border_style(focus_border)
.style(bg_style)
};
let inset_w = inner_w.saturating_sub(2);
let header_render = if header_focused {
Rect::new(inner_x + 1, inner_y, inset_w, 1)
} else {
header_rect
};
let body_render = if content_focused {
Rect::new(inner_x + 1, body_rect.y, inset_w, body_rect.height)
} else {
body_rect
};
let tags_render = if tag_focused {
Rect::new(inner_x + 1, tags_rect.y, inset_w, 1)
} else {
tags_rect
};
let content_max_line = if content_focused {
inset_w.saturating_sub(2)
} else {
max_line
};
let body_lines: Vec<Line> = if note.has_content_selection() {
render_content_with_selection(note, content_max_line, base_style)
} else {
let raw = note.content_lines();
raw.iter()
.map(|l| {
let display = if l.len() as u16 > content_max_line {
let max_len = content_max_line as usize;
format!("{}…", &l[..max_len])
} else {
l.clone()
};
Line::from(parse_md(&display, base_style))
})
.collect()
};
if note.border_style == "hidden" && !note.editing {
if header_focused {
frame.render_widget(
focus_block(),
Rect::new(inner_x, inner_y.saturating_sub(1), inner_w, 3),
);
}
frame.render_widget(Paragraph::new(header_line).style(bg_style), header_render);
if !header_focused && !content_focused {
frame.render_widget(
Paragraph::new(header_sep_line.clone()),
Rect::new(inner_x, header_sep_y, inner_w, 1),
);
}
if content_focused {
frame.render_widget(
focus_block(),
Rect::new(inner_x, header_sep_y, inner_w, body_h + 2),
);
}
frame.render_widget(Paragraph::new(body_lines).style(bg_style), body_render);
if tags_section_h > 0 {
if !content_focused && !tag_focused {
frame.render_widget(
Paragraph::new(tags_sep_line.clone()),
Rect::new(inner_x, tags_sep_y, inner_w, 1),
);
}
if tag_focused {
frame.render_widget(focus_block(), Rect::new(inner_x, tags_sep_y, inner_w, 3));
}
render_tags_chips(
note,
tag_focused,
theme,
bg_style,
tags_render,
frame,
tags_render.width,
);
}
} else {
let block = Block::bordered()
.border_type(border_type)
.border_style(Style::new().fg(border_fg))
.style(bg_style);
frame.render_widget(&block, rect);
if header_focused {
frame.render_widget(
focus_block(),
Rect::new(inner_x, inner_y.saturating_sub(1), inner_w, 3),
);
}
frame.render_widget(Paragraph::new(header_line).style(bg_style), header_render);
if !header_focused && !content_focused {
frame.render_widget(
Paragraph::new(header_sep_line.clone()),
Rect::new(inner_x, header_sep_y, inner_w, 1),
);
}
if content_focused {
frame.render_widget(
focus_block(),
Rect::new(inner_x, header_sep_y, inner_w, body_h + 2),
);
}
frame.render_widget(Paragraph::new(body_lines).style(bg_style), body_render);
if tags_section_h > 0 {
if !content_focused && !tag_focused {
frame.render_widget(
Paragraph::new(tags_sep_line.clone()),
Rect::new(inner_x, tags_sep_y, inner_w, 1),
);
}
if tag_focused {
frame.render_widget(focus_block(), Rect::new(inner_x, tags_sep_y, inner_w, 3));
}
render_tags_chips(
note,
tag_focused,
theme,
bg_style,
tags_render,
frame,
tags_render.width,
);
}
}
if tag_focused && !tag_suggestions.is_empty() && tags_section_h > 0 {
let max_popup_h = tags_sep_y.saturating_sub(body_rect.y).min(6);
let popup_h = ((tag_suggestions.len() as u16) + 2).min(max_popup_h);
if popup_h >= 3 {
let content_rows = (popup_h - 2) as usize;
let popup_rect = Rect::new(inner_x, tags_sep_y - popup_h, inner_w, popup_h);
let visible_suggestions = &tag_suggestions[..tag_suggestions.len().min(content_rows)];
let suggestion_lines: Vec<Line> = visible_suggestions
.iter()
.enumerate()
.map(|(i, s)| {
let style = if i == 0 {
Style::new()
.fg(Color::White)
.bg(Color::Rgb(0x55, 0x55, 0x55))
} else {
Style::new().fg(Color::Rgb(0xcc, 0xcc, 0xcc))
};
Line::from(Span::styled(format!(" {}", s), style))
})
.collect();
frame.render_widget(Clear, popup_rect);
frame.render_widget(
Paragraph::new(suggestion_lines)
.style(Style::new().bg(Color::Rgb(0x22, 0x22, 0x22)))
.block(
Block::bordered()
.border_type(BorderType::Plain)
.border_style(Style::new().fg(Color::Rgb(0x88, 0x88, 0x88))),
),
popup_rect,
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_tags_chips(
note: &Note,
tag_focused: bool,
theme: &Theme,
bg_style: Style,
rect: Rect,
frame: &mut Frame,
inner_w: u16,
) {
let fg = Color::Rgb(0x1a, 0x1a, 0x1a);
if note.tags.is_empty() && note.tag_input.is_empty() && !tag_focused {
let placeholder = format!("{:>width$}", "(no tags)", width = inner_w as usize);
let line = Line::from(Span::styled(placeholder, Style::new().fg(fg).dim()));
frame.render_widget(Paragraph::new(line).style(bg_style), rect);
return;
}
let mut spans: Vec<Span> = Vec::new();
for (i, tag) in note.tags.iter().enumerate() {
let is_selected = tag_focused && note.tag_cursor == Some(i);
let tag_style = if is_selected {
Style::new().fg(theme.sel_border)
} else {
Style::new().fg(fg)
};
spans.push(Span::styled(format!("[{}]", tag), tag_style));
spans.push(Span::styled(" ", Style::new()));
}
if tag_focused || !note.tag_input.is_empty() {
let show_cursor = tag_focused && note.tag_cursor.is_none();
let input_text = if show_cursor {
format!("{}█", note.tag_input)
} else {
note.tag_input.clone()
};
spans.push(Span::styled(input_text, Style::new().fg(fg)));
}
let line = Line::from(spans);
let line_w = line.width() as u16;
let display = if line_w < inner_w {
let pad = (inner_w - line_w) as usize;
let mut padded = vec![Span::styled(" ".repeat(pad), Style::new())];
padded.extend(line.spans);
Line::from(padded)
} else {
line
};
frame.render_widget(Paragraph::new(display).style(bg_style), rect);
}
fn render_overlay(app: &App, frame: &mut Frame, theme: &Theme) {
let area = frame.area();
let w = area.width;
let h = area.height;
if app.count() == 0 || app.selected >= app.count() {
return;
}
let note = &app.notes[app.selected];
let render_h = (h as f64 * 0.6) as u16;
let render_y = (h - render_h) / 3;
let note_rect = Rect::new(1, render_y, w.saturating_sub(2), render_h);
render_full_note(note, theme, false, false, &[], note_rect, frame);
render_bar(
frame,
" Esc:close │ full-screen overlay",
Style::new().bg(theme.status_bg).fg(theme.status_fg),
Rect::new(0, h - 2, w, 1),
);
render_bar(
frame,
" Tab:focus Enter:newline Backspace:delete ←/→:move cursor",
Style::new().bg(theme.hint_bg).fg(theme.hint_fg),
Rect::new(0, h - 1, w, 1),
);
}
fn render_welcome(frame: &mut Frame, area: Rect, theme: &Theme) {
let w = area.width;
let h = area.height;
let lines = [
" Stickynote Board ",
"",
" n new note d delete note",
" e toggle edit c cycle color",
" b cycle border ^d duplicate",
" t add tag T filter by tag",
" / search tags O overlay view",
" ←/→ navigate tabs ^R cycle theme",
"",
" Click a tab to select, double-click to edit",
" Right-click a tab for options",
" Middle-click a tab to delete",
];
let content = lines.join("\n");
let box_w = 42u16;
let box_h = lines.len() as u16 + 4; let start_x = (w.saturating_sub(box_w)) / 2;
let start_y = (h.saturating_sub(box_h)) / 3;
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(theme.status_fg).dim())
.style(Style::new().bg(theme.hint_bg))
.padding(ratatui::widgets::Padding::horizontal(2));
let par = Paragraph::new(content)
.block(block)
.style(Style::new().fg(theme.status_fg));
frame.render_widget(par, Rect::new(start_x, start_y, box_w, box_h));
}
fn render_menu(app: &App, frame: &mut Frame, theme: &Theme) {
let labels: Vec<&str> = app
.menu
.items
.iter()
.map(|a| match a {
MenuAction::Edit => "Edit",
MenuAction::Color => "Color",
MenuAction::Border => "Border",
MenuAction::Tag => "Tag",
MenuAction::Delete => "Delete",
MenuAction::NewNote => "New note",
MenuAction::Close => "Close",
})
.collect();
let inner_w = 14u16;
let menu_h = labels.len() as u16 + 2; let menu_w = inner_w + 2;
let mut items = Vec::new();
for (i, label) in labels.iter().enumerate() {
let is_selected = i == app.menu.selected;
let item_style = if is_selected {
Style::new().bg(theme.sel_border).fg(theme.status_bg)
} else {
Style::new().bg(theme.hint_bg).fg(theme.status_fg)
};
items.push(Line::from(Span::styled(
format!("{label: <width$}", width = inner_w as usize),
item_style,
)));
}
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(theme.sel_border))
.style(Style::new().bg(theme.hint_bg));
let par = Paragraph::new(items).block(block);
let menu_rect = Rect::new(app.menu.x, app.menu.y, menu_w, menu_h);
frame.render_widget(par, menu_rect);
}
fn render_help(frame: &mut Frame, area: Rect, theme: &Theme) {
let w = area.width;
let lines = [
" Stickynote -- Key Bindings ",
"",
" n New note",
" ^d Duplicate note",
" e/enter Toggle edit mode",
" d Delete (confirm)",
" c Cycle colour",
" b Cycle border style",
" t Add tag",
" ^t Clear all tags (confirm)",
" T Toggle tag filter",
" / Filter by tag",
" ←/→ Tags: select tag to delete",
"",
" Inline editing (Tab cycles focus):",
" Header Edit note title",
" Content Edit note body",
" Tags Type + Enter to add | ←/→ select | Del",
"",
" Text selection (Header / Content):",
" ^a Select all",
" ^c Copy selected text",
" ^x Cut selected text",
" ^v Paste from clipboard",
" Shift+←/→ Extend selection left/right",
" Shift+↑/↓ Extend selection up/down",
" Mouse Click to position, drag to select",
"",
" ←/→ Navigate tabs",
" ? Toggle this help",
" ^R Cycle theme",
" esc Cancel / close",
" q Quit",
"",
" Click tab Select note",
" Double-click Select + edit",
" Right-click Context menu",
" Middle-click Delete note",
" Scroll wheel Navigate tabs",
"",
" Press any key or click to close",
];
let content = lines.join("\n");
let box_h = lines.len() as u16 + 2;
let box_w = 56u16.min(w.saturating_sub(4));
let start_x = (w.saturating_sub(box_w)) / 2;
let start_y = (area.height.saturating_sub(box_h)) / 4;
let help_rect = Rect::new(start_x, start_y, box_w, box_h);
frame.render_widget(Clear, help_rect);
let block = Block::bordered()
.border_type(BorderType::Double)
.border_style(Style::new().fg(theme.sel_border))
.style(Style::new().bg(theme.hint_bg))
.padding(ratatui::widgets::Padding::horizontal(2));
let par = Paragraph::new(content)
.block(block)
.style(Style::new().fg(theme.status_fg));
frame.render_widget(par, help_rect);
}
fn render_footer(app: &App, frame: &mut Frame, theme: &Theme, h: u16) {
let w = frame.area().width;
if h >= 2 {
render_bar(
frame,
&app.status_text(),
Style::new().bg(theme.status_bg).fg(theme.status_fg),
Rect::new(0, h - 2, w, 1),
);
}
if h >= 1 {
render_bar(
frame,
app.hint_bar(),
Style::new().bg(theme.hint_bg).fg(theme.hint_fg),
Rect::new(0, h - 1, w, 1),
);
}
}
fn render_par(frame: &mut Frame, text: &str, style: Style, rect: Rect) {
frame.render_widget(Paragraph::new(text).style(style), rect);
}
fn render_bar(frame: &mut Frame, text: &str, style: Style, rect: Rect) {
frame.render_widget(Paragraph::new(text).style(style), rect);
}
fn selection_style() -> Style {
Style::new()
.bg(Color::Rgb(0x55, 0x55, 0x55))
.fg(Color::White)
}
fn render_content_with_selection(note: &Note, max_line: u16, base_style: Style) -> Vec<Line<'_>> {
let Some((sel_s, sel_e)) = note.content_selection_range() else {
return Vec::new();
};
let sel_style = selection_style();
let mut lines = Vec::new();
let mut byte_pos = 0usize;
for line_str in note.content.split('\n') {
let line_start = byte_pos;
let line_end = byte_pos + line_str.len();
let line_len = line_str.len();
let truncated: &str = if line_len as u16 > max_line {
&line_str[..max_line as usize]
} else {
line_str
};
let line = if sel_s < line_end && sel_e > line_start {
let mut spans: Vec<Span> = Vec::new();
let local_s = sel_s.saturating_sub(line_start).min(truncated.len());
let local_e = sel_e.saturating_sub(line_start).min(truncated.len());
if local_s > 0 {
spans.extend(parse_md(&truncated[..local_s], base_style));
}
if local_s < local_e {
spans.push(Span::styled(
truncated[local_s..local_e].to_string(),
sel_style,
));
}
if local_e < truncated.len() {
spans.extend(parse_md(&truncated[local_e..], base_style));
}
Line::from(spans)
} else {
Line::from(parse_md(truncated, base_style))
};
lines.push(line);
byte_pos = line_end + 1;
}
lines
}
fn render_title_with_selection<'a>(
note: &'a Note,
title_width: u16,
_base_style: Style,
theme: &'a Theme,
) -> Line<'a> {
let Some((ts, te)) = note.title_selection_range() else {
return Line::from(Span::styled(
note.title.clone(),
Style::new().fg(theme.sel_border),
));
};
let sel_style = selection_style();
let raw = ¬e.title;
let display = raw.to_string();
let cursor_idx = note.title_cursor.min(display.len());
let cursor_shown = note.editing && note.title_cursor == note.title_sel_end.unwrap_or(0);
let title_len = display.len() as u16;
let (padded, pad_start) = if title_len <= title_width {
let pad = (title_width - title_len) / 2;
(
format!("{:pad$}{}", "", display, pad = pad as usize),
pad as usize,
)
} else {
let max = title_width as usize;
if cursor_shown && cursor_idx >= max {
let offset = cursor_idx.saturating_sub(max.saturating_sub(1));
let tail: String = raw
.chars()
.skip(offset)
.take(max.saturating_sub(1))
.collect();
(format!("{}█", tail), 0usize)
} else {
let max_show = max.saturating_sub(1);
(format!("{}…", &display[..max_show]), 0usize)
}
};
let display_len = padded.len();
let sel_start_display = ts + pad_start;
let sel_end_display = te + pad_start;
let line_style = Style::new().fg(theme.sel_border);
if sel_start_display < display_len {
let mut spans: Vec<Span> = Vec::new();
if sel_start_display > 0 {
spans.push(Span::styled(
padded[..sel_start_display].to_string(),
line_style,
));
}
let sel_end = sel_end_display.min(display_len);
if sel_start_display < sel_end {
let sel_text = &padded[sel_start_display..sel_end];
spans.push(Span::styled(sel_text.to_string(), sel_style));
}
if sel_end < display_len {
let remaining = &padded[sel_end..];
spans.push(Span::styled(remaining.to_string(), line_style));
}
Line::from(spans)
} else {
Line::from(Span::styled(padded, line_style))
}
}