use super::setup::get_grouped_help;
use super::styles::{get_resolved_theme, names};
use super::templates::{
DELETED_HELP_PARTIAL, FULL_PAD_TEMPLATE, LIST_TEMPLATE, MATCH_LINES_PARTIAL, MESSAGES_TEMPLATE,
PAD_LINE_PARTIAL, PEEK_CONTENT_PARTIAL, TEXT_LIST_TEMPLATE,
};
use chrono::{DateTime, Utc};
use outstanding::{render_or_serialize, truncate_to_width, OutputMode, Renderer, ThemeChoice};
use padzapp::api::{CmdMessage, MessageLevel, TodoStatus};
use padzapp::index::{DisplayIndex, DisplayPad};
use padzapp::peek::{format_as_peek, PeekResult};
use serde::Serialize;
fn create_renderer(output_mode: OutputMode) -> Renderer {
let theme = get_resolved_theme();
let mut renderer = Renderer::with_output(theme, output_mode)
.expect("Failed to create renderer - invalid theme aliases");
renderer
.add_template("list", LIST_TEMPLATE)
.expect("Failed to register list template");
renderer
.add_template("full_pad", FULL_PAD_TEMPLATE)
.expect("Failed to register full_pad template");
renderer
.add_template("text_list", TEXT_LIST_TEMPLATE)
.expect("Failed to register text_list template");
renderer
.add_template("messages", MESSAGES_TEMPLATE)
.expect("Failed to register messages template");
renderer
.add_template("_deleted_help", DELETED_HELP_PARTIAL)
.expect("Failed to register _deleted_help partial");
renderer
.add_template("_peek_content", PEEK_CONTENT_PARTIAL)
.expect("Failed to register _peek_content partial");
renderer
.add_template("_match_lines", MATCH_LINES_PARTIAL)
.expect("Failed to register _match_lines partial");
renderer
.add_template("_pad_line", PAD_LINE_PARTIAL)
.expect("Failed to register _pad_line partial");
renderer
}
pub const LINE_WIDTH: usize = 100;
pub const PIN_MARKER: &str = "âš²";
pub const COL_LEFT_PIN: usize = 2; pub const COL_STATUS: usize = 2; pub const COL_INDEX: usize = 4; pub const COL_RIGHT_PIN: usize = 2; pub const COL_TIME: usize = 14;
pub const STATUS_PLANNED: &str = "⚪︎";
pub const STATUS_IN_PROGRESS: &str = "☉︎︎";
pub const STATUS_DONE: &str = "⚫︎";
#[derive(Serialize)]
struct MatchSegmentData {
text: String,
style: String,
}
#[derive(Serialize)]
struct MatchLineData {
segments: Vec<MatchSegmentData>,
line_number: String,
}
#[derive(Serialize)]
struct PadLineData {
indent: String, left_pin: String, status_icon: String, index: String, title: String, title_width: usize, right_pin: String, time_ago: String, is_pinned_section: bool, is_deleted: bool, is_separator: bool, matches: Vec<MatchLineData>,
more_matches_count: usize,
peek: Option<PeekResult>,
}
#[derive(Serialize)]
struct ListData {
pads: Vec<PadLineData>,
empty: bool,
pin_marker: String,
help_text: String,
deleted_help: bool,
peek: bool,
col_left_pin: usize,
col_status: usize,
col_index: usize,
col_right_pin: usize,
col_time: usize,
}
#[derive(Serialize)]
struct FullPadData {
pads: Vec<FullPadEntry>,
}
#[derive(Serialize)]
struct FullPadEntry {
index: String,
title: String,
content: String,
is_pinned: bool,
is_deleted: bool,
}
#[derive(Serialize)]
struct TextListData {
lines: Vec<String>,
empty_message: String,
}
#[derive(Serialize)]
struct JsonPadList {
pads: Vec<DisplayPad>,
}
#[derive(Serialize)]
struct MessageData {
content: String,
style: String,
}
#[derive(Serialize)]
struct MessagesData {
messages: Vec<MessageData>,
}
pub fn render_pad_list(pads: &[DisplayPad], peek: bool, output_mode: OutputMode) -> String {
render_pad_list_internal(pads, Some(output_mode), false, peek)
}
pub fn render_pad_list_deleted(pads: &[DisplayPad], peek: bool, output_mode: OutputMode) -> String {
render_pad_list_internal(pads, Some(output_mode), true, peek)
}
fn render_pad_list_internal(
pads: &[DisplayPad],
output_mode: Option<OutputMode>,
show_deleted_help: bool,
peek: bool,
) -> String {
let mode = output_mode.unwrap_or(OutputMode::Auto);
if mode == OutputMode::Json {
let json_data = JsonPadList {
pads: pads.to_vec(),
};
let theme = get_resolved_theme();
return render_or_serialize(
"", &json_data,
ThemeChoice::from(&theme),
mode,
)
.unwrap_or_else(|_| "{\"pads\":[]}".to_string());
}
let empty_data = ListData {
pads: vec![],
empty: true,
pin_marker: PIN_MARKER.to_string(),
help_text: get_grouped_help(),
deleted_help: false,
peek: false,
col_left_pin: COL_LEFT_PIN,
col_status: COL_STATUS,
col_index: COL_INDEX,
col_right_pin: COL_RIGHT_PIN,
col_time: COL_TIME,
};
if pads.is_empty() {
let renderer = create_renderer(mode);
return renderer
.render("list", &empty_data)
.unwrap_or_else(|_| "No pads found.\n".to_string());
}
let mut pad_lines = Vec::new();
let mut last_was_pinned = false;
fn process_pad(
dp: &DisplayPad,
pad_lines: &mut Vec<PadLineData>,
_prefix: &str, depth: usize,
is_pinned_section: bool,
is_deleted_root: bool,
peek: bool,
) {
let is_deleted = matches!(dp.index, DisplayIndex::Deleted(_));
let show_right_pin = dp.pad.metadata.is_pinned && !is_pinned_section;
let local_idx_str = match &dp.index {
DisplayIndex::Pinned(n) => format!("p{}", n),
DisplayIndex::Regular(n) => format!("{:2}", n), DisplayIndex::Deleted(n) => format!("d{}", n),
};
let full_idx_str = format!("{}.", local_idx_str);
let status_icon = match dp.pad.metadata.status {
TodoStatus::Planned => STATUS_PLANNED,
TodoStatus::InProgress => STATUS_IN_PROGRESS,
TodoStatus::Done => STATUS_DONE,
}
.to_string();
let indent_width = if is_pinned_section {
depth.saturating_sub(1) * 2
} else {
depth * 2
};
let total_indent_width = indent_width + COL_LEFT_PIN; let indent = " ".repeat(indent_width);
let left_pin = if is_pinned_section && depth == 0 {
PIN_MARKER.to_string()
} else {
String::new()
};
let right_pin = if show_right_pin {
PIN_MARKER.to_string()
} else {
String::new()
};
let fixed_columns = COL_LEFT_PIN + COL_STATUS + COL_INDEX + COL_RIGHT_PIN + COL_TIME;
let title_width = LINE_WIDTH.saturating_sub(fixed_columns + total_indent_width);
let mut match_lines = Vec::new();
if let Some(matches) = &dp.matches {
for m in matches {
if m.line_number == 0 {
continue;
}
let segments: Vec<MatchSegmentData> = m
.segments
.iter()
.map(|s| match s {
padzapp::index::MatchSegment::Plain(t) => MatchSegmentData {
text: t.clone(),
style: names::INFO.to_string(),
},
padzapp::index::MatchSegment::Match(t) => MatchSegmentData {
text: t.clone(),
style: names::MATCH.to_string(),
},
})
.collect();
let match_indent = total_indent_width + COL_LEFT_PIN + COL_STATUS + COL_INDEX;
let match_available = LINE_WIDTH.saturating_sub(COL_TIME + match_indent);
let truncated = truncate_match_segments(segments, match_available);
match_lines.push(MatchLineData {
line_number: format!("{:02}", m.line_number),
segments: truncated,
});
}
}
let peek_data = if peek {
let body_lines: Vec<&str> = dp.pad.content.lines().skip(1).collect();
let body = body_lines.join("\n");
let result = format_as_peek(&body, 3);
if result.opening_lines.is_empty() {
None
} else {
Some(result)
}
} else {
None
};
pad_lines.push(PadLineData {
indent,
left_pin,
status_icon,
index: full_idx_str.clone(),
title: dp.pad.metadata.title.clone(), title_width,
right_pin,
time_ago: format_time_ago(dp.pad.metadata.created_at),
is_pinned_section: is_pinned_section && depth == 0,
is_deleted: is_deleted || is_deleted_root,
is_separator: false,
matches: match_lines,
more_matches_count: 0,
peek: peek_data,
});
for child in &dp.children {
process_pad(
child,
pad_lines,
&full_idx_str,
depth + 1,
is_pinned_section,
is_deleted_root,
peek,
);
}
}
for dp in pads {
let is_pinned_section = matches!(dp.index, DisplayIndex::Pinned(_));
let is_deleted_section = matches!(dp.index, DisplayIndex::Deleted(_));
if last_was_pinned && !is_pinned_section {
pad_lines.push(PadLineData {
indent: String::new(),
left_pin: String::new(),
status_icon: String::new(),
index: String::new(),
title: String::new(),
title_width: 0,
right_pin: String::new(),
time_ago: String::new(),
is_pinned_section: false,
is_deleted: false,
is_separator: true,
matches: vec![],
more_matches_count: 0,
peek: None,
});
}
last_was_pinned = is_pinned_section;
process_pad(
dp,
&mut pad_lines,
"",
0,
is_pinned_section,
is_deleted_section,
peek,
);
}
let data = ListData {
pads: pad_lines,
empty: false,
pin_marker: PIN_MARKER.to_string(),
help_text: String::new(), deleted_help: show_deleted_help,
peek,
col_left_pin: COL_LEFT_PIN,
col_status: COL_STATUS,
col_index: COL_INDEX,
col_right_pin: COL_RIGHT_PIN,
col_time: COL_TIME,
};
let renderer = create_renderer(mode);
renderer
.render("list", &data)
.unwrap_or_else(|e| format!("Render error: {}\n", e))
}
fn truncate_match_segments(
segments: Vec<MatchSegmentData>,
max_width: usize,
) -> Vec<MatchSegmentData> {
use unicode_width::UnicodeWidthStr;
let mut result = Vec::new();
let mut current_width = 0;
for seg in segments {
let w = seg.text.width();
if current_width + w <= max_width {
result.push(seg);
current_width += w;
} else {
let remaining = max_width.saturating_sub(current_width);
let truncated = truncate_to_width(&seg.text, remaining);
result.push(MatchSegmentData {
text: truncated,
style: seg.style,
});
return result;
}
}
result
}
pub fn render_full_pads(pads: &[DisplayPad], output_mode: OutputMode) -> String {
render_full_pads_internal(pads, Some(output_mode))
}
fn render_full_pads_internal(pads: &[DisplayPad], output_mode: Option<OutputMode>) -> String {
let mode = output_mode.unwrap_or(OutputMode::Auto);
if mode == OutputMode::Json {
let json_data = JsonPadList {
pads: pads.to_vec(),
};
let theme = get_resolved_theme();
return render_or_serialize(
"", &json_data,
ThemeChoice::from(&theme),
mode,
)
.unwrap_or_else(|_| "{\"pads\":[]}".to_string());
}
let entries = pads
.iter()
.map(|dp| {
let is_pinned = matches!(dp.index, DisplayIndex::Pinned(_));
let is_deleted = matches!(dp.index, DisplayIndex::Deleted(_));
FullPadEntry {
index: format!("{}", dp.index),
title: dp.pad.metadata.title.clone(),
content: dp.pad.content.clone(),
is_pinned,
is_deleted,
}
})
.collect();
let data = FullPadData { pads: entries };
let renderer = create_renderer(mode);
renderer
.render("full_pad", &data)
.unwrap_or_else(|e| format!("Render error: {}\n", e))
}
pub fn render_text_list(lines: &[String], empty_message: &str, output_mode: OutputMode) -> String {
render_text_list_internal(lines, empty_message, Some(output_mode))
}
fn render_text_list_internal(
lines: &[String],
empty_message: &str,
output_mode: Option<OutputMode>,
) -> String {
let mode = output_mode.unwrap_or(OutputMode::Auto);
if mode == OutputMode::Json {
let json_data = TextListData {
lines: lines.to_vec(),
empty_message: empty_message.to_string(),
};
let theme = get_resolved_theme();
return render_or_serialize(
"", &json_data,
ThemeChoice::from(&theme),
mode,
)
.unwrap_or_else(|_| "{\"lines\":[]}".to_string());
}
let data = TextListData {
lines: lines.to_vec(),
empty_message: empty_message.to_string(),
};
let renderer = create_renderer(mode);
renderer
.render("text_list", &data)
.unwrap_or_else(|_| format!("{}\n", empty_message))
}
#[derive(Serialize)]
struct JsonMessages {
messages: Vec<CmdMessage>,
}
pub fn render_messages(messages: &[CmdMessage], output_mode: OutputMode) -> String {
if messages.is_empty() {
return String::new();
}
if output_mode == OutputMode::Json {
let json_data = JsonMessages {
messages: messages.to_vec(),
};
let theme = get_resolved_theme();
return render_or_serialize(
"", &json_data,
ThemeChoice::from(&theme),
output_mode,
)
.unwrap_or_else(|_| "{}".to_string());
}
let message_data: Vec<MessageData> = messages
.iter()
.map(|msg| {
let style = match msg.level {
MessageLevel::Info => names::INFO,
MessageLevel::Success => names::SUCCESS,
MessageLevel::Warning => names::WARNING,
MessageLevel::Error => names::ERROR,
};
MessageData {
content: msg.content.clone(),
style: style.to_string(),
}
})
.collect();
let data = MessagesData {
messages: message_data,
};
let renderer = create_renderer(output_mode);
renderer.render("messages", &data).unwrap_or_else(|_| {
messages
.iter()
.map(|m| format!("{}\n", m.content))
.collect()
})
}
pub fn print_messages(messages: &[CmdMessage], output_mode: OutputMode) {
let output = render_messages(messages, output_mode);
if !output.is_empty() {
print!("{}", output);
}
}
fn format_time_ago(timestamp: DateTime<Utc>) -> String {
let now = Utc::now();
let duration = now.signed_duration_since(timestamp);
let formatter = timeago::Formatter::new();
let time_str = formatter.convert(duration.to_std().unwrap_or_default());
time_str
.replace("hours ago", " hours ago") .replace("hour ago", " hour ago") .replace("days ago", " days ago") .replace("day ago", " day ago") .replace("weeks ago", " weeks ago") .replace("week ago", " week ago") .replace("months ago", " months ago") .replace("month ago", " month ago") .replace("years ago", " years ago") .replace("year ago", " year ago") }
#[cfg(test)]
mod tests {
use super::*;
use padzapp::model::Pad;
fn make_pad(title: &str, pinned: bool, deleted: bool) -> Pad {
let mut p = Pad::new(title.to_string(), "some content".to_string());
p.metadata.is_pinned = pinned;
p.metadata.is_deleted = deleted;
p
}
fn make_display_pad(pad: Pad, index: DisplayIndex) -> DisplayPad {
DisplayPad {
pad,
index,
matches: None,
children: vec![],
}
}
#[test]
fn test_render_empty_list() {
let output = render_pad_list_internal(&[], Some(OutputMode::Text), false, false);
assert!(output.contains("No pads yet, create one with `padz create`"));
}
#[test]
fn test_render_single_regular_pad() {
let pad = make_pad("Test Note", false, false);
let dp = make_display_pad(pad, DisplayIndex::Regular(1));
let output = render_pad_list_internal(&[dp], Some(OutputMode::Text), false, false);
assert!(output.contains(STATUS_PLANNED)); assert!(output.contains(" 1."));
assert!(output.contains("Test Note"));
assert!(output.contains(&format!("{} ", STATUS_PLANNED)));
assert!(output.contains(&format!("{} {}.", STATUS_PLANNED, " 1")));
}
#[test]
fn test_render_pinned_pad() {
let pad = make_pad("Pinned Note", true, false);
let dp = make_display_pad(pad, DisplayIndex::Pinned(1));
let output = render_pad_list_internal(&[dp], Some(OutputMode::Text), false, false);
assert!(output.contains("p1."));
assert!(output.contains("Pinned Note"));
assert!(output.contains(PIN_MARKER));
}
#[test]
fn test_render_deleted_pad() {
let pad = make_pad("Deleted Note", false, true);
let dp = make_display_pad(pad, DisplayIndex::Deleted(1));
let output = render_pad_list_internal(&[dp], Some(OutputMode::Text), false, false);
assert!(output.contains("d1."));
assert!(output.contains("Deleted Note"));
}
#[test]
fn test_render_mixed_pinned_and_regular() {
let pinned = make_pad("Pinned", true, false);
let regular = make_pad("Regular", false, false);
let pads = vec![
make_display_pad(pinned.clone(), DisplayIndex::Pinned(1)),
make_display_pad(regular, DisplayIndex::Regular(1)),
make_display_pad(pinned, DisplayIndex::Regular(2)),
];
let output = render_pad_list_internal(&pads, Some(OutputMode::Text), false, false);
assert!(output.contains("p1."));
let lines: Vec<&str> = output.lines().collect();
assert!(lines.iter().any(|l| l.trim().is_empty()));
}
#[test]
fn test_render_pinned_marker_on_regular_entry() {
let mut pad = make_pad("Pinned Note", true, false);
pad.metadata.is_pinned = true;
let dp = make_display_pad(pad, DisplayIndex::Regular(1));
let output = render_pad_list_internal(&[dp], Some(OutputMode::Text), false, false);
assert!(output.contains(PIN_MARKER));
}
#[test]
fn test_render_with_color_includes_ansi() {
let pad = make_pad("Test", false, false);
let dp = make_display_pad(pad, DisplayIndex::Regular(1));
let output = render_pad_list_internal(&[dp], Some(OutputMode::Term), false, false);
assert!(output.contains("Test"));
}
#[test]
fn test_render_search_results() {
use padzapp::index::{MatchSegment, SearchMatch};
let pad = make_pad("Search Result", false, false);
let mut dp = make_display_pad(pad, DisplayIndex::Regular(1));
dp.matches = Some(vec![SearchMatch {
line_number: 2,
segments: vec![
MatchSegment::Plain("Found ".to_string()),
MatchSegment::Match("match".to_string()),
MatchSegment::Plain(" here".to_string()),
],
}]);
let output = render_pad_list_internal(&[dp], Some(OutputMode::Text), false, false);
assert!(output.contains("1."));
assert!(output.contains("Search Result"));
assert!(output.contains(" 02 Found match here"));
}
#[test]
fn test_render_full_pads_empty() {
let output = render_full_pads_internal(&[], Some(OutputMode::Text));
assert!(output.contains("No pads found."));
}
#[test]
fn test_render_full_pads_single() {
let pad = make_pad("Full Pad", false, false);
let dp = make_display_pad(pad, DisplayIndex::Regular(3));
let output = render_full_pads_internal(&[dp], Some(OutputMode::Text));
assert!(output.contains("3 Full Pad"));
assert!(output.contains("some content"));
let lines: Vec<&str> = output.lines().collect();
let header_index = lines
.iter()
.position(|line| line.contains("3 Full Pad"))
.expect("header line missing");
let spacer = lines.get(header_index + 1).copied().unwrap_or_default();
assert!(
spacer.trim().is_empty(),
"expected blank separator line between title and content, got: {:?}",
spacer
);
let body_section = &lines[(header_index + 2).min(lines.len())..];
assert!(
body_section
.iter()
.any(|line| line.contains("some content")),
"expected rendered body to include pad content"
);
}
#[test]
fn test_render_text_list_empty() {
let output = render_text_list_internal(&[], "Nothing here.", Some(OutputMode::Text));
assert!(output.contains("Nothing here."));
}
#[test]
fn test_render_text_list_lines() {
let lines = vec!["first".to_string(), "second".to_string()];
let output = render_text_list_internal(&lines, "", Some(OutputMode::Text));
assert!(output.contains("first"));
assert!(output.contains("second"));
}
#[test]
fn test_render_messages_empty() {
let output = render_messages(&[], OutputMode::Auto);
assert!(output.is_empty());
}
#[test]
fn test_render_messages_success() {
let messages = vec![CmdMessage::success("Pad created: Test")];
let output = render_messages(&messages, OutputMode::Auto);
assert!(output.contains("Pad created: Test"));
}
#[test]
fn test_render_messages_multiple() {
let messages = vec![
CmdMessage::info("Info message"),
CmdMessage::warning("Warning message"),
CmdMessage::error("Error message"),
];
let output = render_messages(&messages, OutputMode::Auto);
assert!(output.contains("Info message"));
assert!(output.contains("Warning message"));
assert!(output.contains("Error message"));
}
#[test]
fn test_render_messages_json() {
let messages = vec![
CmdMessage::success("Operation completed"),
CmdMessage::info("Additional info"),
];
let output = render_messages(&messages, OutputMode::Json);
assert!(output.contains("\"level\": \"success\""));
assert!(output.contains("\"content\": \"Operation completed\""));
assert!(output.contains("\"level\": \"info\""));
}
#[test]
fn test_format_time_ago_alignment() {
use chrono::Duration;
let now = Utc::now();
let test_cases = [
(Duration::seconds(30), "seconds ago"), (Duration::minutes(5), "minutes ago"), (Duration::hours(2), " hours ago"), (Duration::days(3), " days ago"), (Duration::weeks(1), " week ago"), (Duration::days(45), " month ago"), (Duration::days(400), " year ago"), ];
for (duration, expected_pattern) in test_cases {
let timestamp = now - duration;
let formatted = format_time_ago(timestamp);
assert!(
formatted.contains(expected_pattern),
"Expected '{}' to contain '{}' for duration {:?}",
formatted.trim(),
expected_pattern,
duration
);
}
}
}