v0.0.42: markdown rendering in TUI messages
- **bold**, *italic*, \`code\` rendered with ratatui styles - # headers, > blockquotes, - bullet lists - Multi-line messages split and indented per line - Code spans: cyan bold, headers: white bold, quotes: gray italic - No external dependency (custom md_to_spans parser) - tui-markdown had ratatui version mismatch, built our own Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
warzone/Cargo.lock
generated
10
warzone/Cargo.lock
generated
@@ -2956,7 +2956,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-client"
|
||||
version = "0.0.40"
|
||||
version = "0.0.42"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -2989,7 +2989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-mule"
|
||||
version = "0.0.40"
|
||||
version = "0.0.42"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -2998,7 +2998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.40"
|
||||
version = "0.0.42"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
@@ -3023,7 +3023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-server"
|
||||
version = "0.0.40"
|
||||
version = "0.0.42"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3054,7 +3054,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-wasm"
|
||||
version = "0.0.40"
|
||||
version = "0.0.42"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.41"
|
||||
version = "0.0.42"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
@@ -10,6 +10,60 @@ use chrono::Local;
|
||||
|
||||
use super::types::{App, ReceiptStatus};
|
||||
|
||||
/// Simple markdown-to-spans converter for TUI messages.
|
||||
/// Handles: **bold**, *italic*, `code`, ```code blocks```.
|
||||
fn md_to_spans<'a>(text: &'a str, base_style: Style) -> Vec<Span<'a>> {
|
||||
let mut spans = Vec::new();
|
||||
let mut remaining = text;
|
||||
|
||||
while !remaining.is_empty() {
|
||||
// Code: `...`
|
||||
if remaining.starts_with('`') && !remaining.starts_with("```") {
|
||||
if let Some(end) = remaining[1..].find('`') {
|
||||
spans.push(Span::styled(
|
||||
&remaining[1..1 + end],
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
remaining = &remaining[2 + end..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Bold: **...**
|
||||
if remaining.starts_with("**") {
|
||||
if let Some(end) = remaining[2..].find("**") {
|
||||
spans.push(Span::styled(
|
||||
&remaining[2..2 + end],
|
||||
base_style.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
remaining = &remaining[4 + end..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Italic: *...*
|
||||
if remaining.starts_with('*') && !remaining.starts_with("**") {
|
||||
if let Some(end) = remaining[1..].find('*') {
|
||||
spans.push(Span::styled(
|
||||
&remaining[1..1 + end],
|
||||
base_style.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
remaining = &remaining[2 + end..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Plain text until next special char
|
||||
let next = remaining.find(|c: char| c == '*' || c == '`').unwrap_or(remaining.len());
|
||||
if next > 0 {
|
||||
spans.push(Span::styled(&remaining[..next], base_style));
|
||||
remaining = &remaining[next..];
|
||||
} else {
|
||||
// Stuck on a special char that didn't match a pattern — emit it
|
||||
spans.push(Span::styled(&remaining[..1], base_style));
|
||||
remaining = &remaining[1..];
|
||||
}
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
|
||||
match message_id {
|
||||
@@ -103,12 +157,12 @@ impl App {
|
||||
]));
|
||||
frame.render_widget(header, chunks[0]);
|
||||
|
||||
// Messages
|
||||
// Messages — render markdown for message bodies via tui-markdown
|
||||
let msgs = self.messages.lock().unwrap();
|
||||
let items: Vec<ListItem> = msgs
|
||||
.iter()
|
||||
.flat_map(|m| {
|
||||
let style = if m.is_system {
|
||||
let base_style = if m.is_system {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else if m.is_self {
|
||||
Style::default().fg(Color::Green)
|
||||
@@ -130,54 +184,51 @@ impl App {
|
||||
};
|
||||
let receipt_color = self.receipt_color(&m.message_id);
|
||||
|
||||
// Calculate available width for text
|
||||
let ts_len = timestamp.len();
|
||||
let prefix_len = prefix.len();
|
||||
let receipt_len = receipt_str.len();
|
||||
let available = (chunks[1].width as usize).saturating_sub(ts_len + prefix_len + receipt_len + 2);
|
||||
// Split text into lines, render markdown per line
|
||||
let text_lines: Vec<&str> = m.text.split('\n').collect();
|
||||
let mut result_items = Vec::new();
|
||||
|
||||
if available == 0 || m.text.len() <= available {
|
||||
// Single line
|
||||
for (i, line_text) in text_lines.iter().enumerate() {
|
||||
let mut spans = Vec::new();
|
||||
if i == 0 {
|
||||
spans.push(Span::styled(timestamp.clone(), Style::default().fg(Color::DarkGray)));
|
||||
spans.push(Span::styled(prefix.clone(), base_style.add_modifier(Modifier::BOLD)));
|
||||
} else {
|
||||
let indent = " ".repeat(timestamp.len() + prefix.len());
|
||||
spans.push(Span::raw(indent));
|
||||
}
|
||||
|
||||
// Check for code block lines (```)
|
||||
if line_text.starts_with("```") {
|
||||
spans.push(Span::styled(*line_text, Style::default().fg(Color::DarkGray)));
|
||||
} else if line_text.starts_with("# ") {
|
||||
spans.push(Span::styled(&line_text[2..], Style::default().fg(Color::White).add_modifier(Modifier::BOLD)));
|
||||
} else if line_text.starts_with("## ") {
|
||||
spans.push(Span::styled(&line_text[3..], Style::default().fg(Color::White).add_modifier(Modifier::BOLD)));
|
||||
} else if line_text.starts_with("> ") {
|
||||
spans.push(Span::styled("│ ", Style::default().fg(Color::DarkGray)));
|
||||
spans.push(Span::styled(&line_text[2..], Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC)));
|
||||
} else if line_text.starts_with("- ") || line_text.starts_with("* ") {
|
||||
spans.push(Span::styled("• ", base_style));
|
||||
spans.extend(md_to_spans(&line_text[2..], base_style));
|
||||
} else {
|
||||
spans.extend(md_to_spans(line_text, base_style));
|
||||
}
|
||||
|
||||
// Receipt on last line
|
||||
if i == text_lines.len() - 1 {
|
||||
spans.push(Span::styled(receipt_str, Style::default().fg(receipt_color)));
|
||||
}
|
||||
result_items.push(ListItem::new(Line::from(spans)));
|
||||
}
|
||||
|
||||
if result_items.is_empty() {
|
||||
vec![ListItem::new(Line::from(vec![
|
||||
Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
|
||||
Span::raw(&m.text),
|
||||
Span::styled(receipt_str, Style::default().fg(receipt_color)),
|
||||
Span::styled(prefix, base_style.add_modifier(Modifier::BOLD)),
|
||||
]))]
|
||||
} else {
|
||||
// Wrap into multiple lines
|
||||
let mut lines = Vec::new();
|
||||
let mut remaining = m.text.as_str();
|
||||
let mut first = true;
|
||||
while !remaining.is_empty() {
|
||||
let (chunk, rest) = if remaining.len() > available {
|
||||
// Try to break at word boundary
|
||||
let break_at = remaining[..available].rfind(' ').unwrap_or(available);
|
||||
(&remaining[..break_at], remaining[break_at..].trim_start())
|
||||
} else {
|
||||
(remaining, "")
|
||||
};
|
||||
|
||||
if first {
|
||||
lines.push(ListItem::new(Line::from(vec![
|
||||
Span::styled(timestamp.clone(), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(prefix.clone(), style.add_modifier(Modifier::BOLD)),
|
||||
Span::raw(chunk),
|
||||
if rest.is_empty() { Span::styled(receipt_str, Style::default().fg(receipt_color)) } else { Span::raw("") },
|
||||
])));
|
||||
first = false;
|
||||
} else {
|
||||
// Continuation lines: indent to align with text
|
||||
let indent = " ".repeat(ts_len + prefix_len);
|
||||
lines.push(ListItem::new(Line::from(vec![
|
||||
Span::raw(indent),
|
||||
Span::styled(chunk, style),
|
||||
if rest.is_empty() { Span::styled(receipt_str, Style::default().fg(receipt_color)) } else { Span::raw("") },
|
||||
])));
|
||||
}
|
||||
remaining = rest;
|
||||
}
|
||||
lines
|
||||
result_items
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.41"
|
||||
version = "0.0.42"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"
|
||||
|
||||
@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
|
||||
|
||||
async fn service_worker() -> impl IntoResponse {
|
||||
([(header::CONTENT_TYPE, "application/javascript")], r##"
|
||||
const CACHE = 'wz-v23';
|
||||
const CACHE = 'wz-v24';
|
||||
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
@@ -287,7 +287,7 @@ let pollTimer = null;
|
||||
let ws = null; // WebSocket connection
|
||||
let wasmReady = false;
|
||||
|
||||
const VERSION = '0.0.41';
|
||||
const VERSION = '0.0.42';
|
||||
let DEBUG = true; // toggle with /debug command
|
||||
|
||||
// ── Receipt tracking ──
|
||||
|
||||
Reference in New Issue
Block a user