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]]
|
[[package]]
|
||||||
name = "warzone-client"
|
name = "warzone-client"
|
||||||
version = "0.0.40"
|
version = "0.0.42"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -2989,7 +2989,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-mule"
|
name = "warzone-mule"
|
||||||
version = "0.0.40"
|
version = "0.0.42"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2998,7 +2998,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.40"
|
version = "0.0.42"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
@@ -3023,7 +3023,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-server"
|
name = "warzone-server"
|
||||||
version = "0.0.40"
|
version = "0.0.42"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3054,7 +3054,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-wasm"
|
name = "warzone-wasm"
|
||||||
version = "0.0.40"
|
version = "0.0.42"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.41"
|
version = "0.0.42"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
|
|||||||
@@ -10,6 +10,60 @@ use chrono::Local;
|
|||||||
|
|
||||||
use super::types::{App, ReceiptStatus};
|
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 {
|
impl App {
|
||||||
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
|
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
|
||||||
match message_id {
|
match message_id {
|
||||||
@@ -103,12 +157,12 @@ impl App {
|
|||||||
]));
|
]));
|
||||||
frame.render_widget(header, chunks[0]);
|
frame.render_widget(header, chunks[0]);
|
||||||
|
|
||||||
// Messages
|
// Messages — render markdown for message bodies via tui-markdown
|
||||||
let msgs = self.messages.lock().unwrap();
|
let msgs = self.messages.lock().unwrap();
|
||||||
let items: Vec<ListItem> = msgs
|
let items: Vec<ListItem> = msgs
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|m| {
|
.flat_map(|m| {
|
||||||
let style = if m.is_system {
|
let base_style = if m.is_system {
|
||||||
Style::default().fg(Color::Cyan)
|
Style::default().fg(Color::Cyan)
|
||||||
} else if m.is_self {
|
} else if m.is_self {
|
||||||
Style::default().fg(Color::Green)
|
Style::default().fg(Color::Green)
|
||||||
@@ -130,54 +184,51 @@ impl App {
|
|||||||
};
|
};
|
||||||
let receipt_color = self.receipt_color(&m.message_id);
|
let receipt_color = self.receipt_color(&m.message_id);
|
||||||
|
|
||||||
// Calculate available width for text
|
// Split text into lines, render markdown per line
|
||||||
let ts_len = timestamp.len();
|
let text_lines: Vec<&str> = m.text.split('\n').collect();
|
||||||
let prefix_len = prefix.len();
|
let mut result_items = Vec::new();
|
||||||
let receipt_len = receipt_str.len();
|
|
||||||
let available = (chunks[1].width as usize).saturating_sub(ts_len + prefix_len + receipt_len + 2);
|
|
||||||
|
|
||||||
if available == 0 || m.text.len() <= available {
|
for (i, line_text) in text_lines.iter().enumerate() {
|
||||||
// Single line
|
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![
|
vec![ListItem::new(Line::from(vec![
|
||||||
Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
|
Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
|
||||||
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
|
Span::styled(prefix, base_style.add_modifier(Modifier::BOLD)),
|
||||||
Span::raw(&m.text),
|
|
||||||
Span::styled(receipt_str, Style::default().fg(receipt_color)),
|
|
||||||
]))]
|
]))]
|
||||||
} else {
|
} else {
|
||||||
// Wrap into multiple lines
|
result_items
|
||||||
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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.41"
|
version = "0.0.42"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"
|
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 {
|
async fn service_worker() -> impl IntoResponse {
|
||||||
([(header::CONTENT_TYPE, "application/javascript")], r##"
|
([(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'];
|
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
||||||
|
|
||||||
self.addEventListener('install', e => {
|
self.addEventListener('install', e => {
|
||||||
@@ -287,7 +287,7 @@ let pollTimer = null;
|
|||||||
let ws = null; // WebSocket connection
|
let ws = null; // WebSocket connection
|
||||||
let wasmReady = false;
|
let wasmReady = false;
|
||||||
|
|
||||||
const VERSION = '0.0.41';
|
const VERSION = '0.0.42';
|
||||||
let DEBUG = true; // toggle with /debug command
|
let DEBUG = true; // toggle with /debug command
|
||||||
|
|
||||||
// ── Receipt tracking ──
|
// ── Receipt tracking ──
|
||||||
|
|||||||
Reference in New Issue
Block a user