- Introduce new `owlen-markdown` crate that converts Markdown strings to `ratatui::Text` with headings, lists, bold/italic, and inline code. - Add `render_markdown` config option (default true) and expose it via `app.render_markdown_enabled()`. - Implement `:markdown [on|off]` command to toggle markdown rendering. - Update help overlay to document the new markdown toggle. - Adjust UI rendering to conditionally apply markdown styling based on the markdown flag and code mode. - Wire the new crate into `owlen-tui` Cargo.toml.
161 lines
5.0 KiB
Rust
161 lines
5.0 KiB
Rust
use once_cell::sync::Lazy;
|
|
use ratatui::style::{Color as TuiColor, Modifier, Style as TuiStyle};
|
|
use std::path::{Path, PathBuf};
|
|
use syntect::easy::HighlightLines;
|
|
use syntect::highlighting::{FontStyle, Style as SynStyle, Theme, ThemeSet};
|
|
use syntect::parsing::{SyntaxReference, SyntaxSet};
|
|
|
|
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
|
|
static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
|
|
static THEME: Lazy<Theme> = Lazy::new(|| {
|
|
THEME_SET
|
|
.themes
|
|
.get("base16-ocean.dark")
|
|
.cloned()
|
|
.or_else(|| THEME_SET.themes.values().next().cloned())
|
|
.unwrap_or_default()
|
|
});
|
|
|
|
fn select_syntax(path_hint: Option<&Path>) -> &'static SyntaxReference {
|
|
if let Some(path) = path_hint {
|
|
if let Ok(Some(syntax)) = SYNTAX_SET.find_syntax_for_file(path) {
|
|
return syntax;
|
|
}
|
|
if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
|
|
if let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(ext) {
|
|
return syntax;
|
|
}
|
|
}
|
|
if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
|
|
if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(name) {
|
|
return syntax;
|
|
}
|
|
}
|
|
}
|
|
|
|
SYNTAX_SET.find_syntax_plain_text()
|
|
}
|
|
|
|
fn select_syntax_for_language(language: Option<&str>) -> &'static SyntaxReference {
|
|
let token = language
|
|
.map(|lang| lang.trim().to_ascii_lowercase())
|
|
.filter(|lang| !lang.is_empty());
|
|
|
|
if let Some(token) = token {
|
|
let mut attempts: Vec<&str> = vec![token.as_str()];
|
|
match token.as_str() {
|
|
"c++" => attempts.extend(["cpp", "c"]),
|
|
"c#" | "cs" => attempts.extend(["csharp", "cs"]),
|
|
"shell" => attempts.extend(["bash", "sh"]),
|
|
"typescript" | "ts" => attempts.extend(["typescript", "ts", "tsx"]),
|
|
"javascript" | "js" => attempts.extend(["javascript", "js", "jsx"]),
|
|
"py" => attempts.push("python"),
|
|
"rs" => attempts.push("rust"),
|
|
"yml" => attempts.push("yaml"),
|
|
other => {
|
|
if let Some(stripped) = other.strip_prefix('.') {
|
|
attempts.push(stripped);
|
|
}
|
|
}
|
|
}
|
|
|
|
for candidate in attempts {
|
|
if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(candidate) {
|
|
return syntax;
|
|
}
|
|
if let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(candidate) {
|
|
return syntax;
|
|
}
|
|
}
|
|
}
|
|
|
|
SYNTAX_SET.find_syntax_plain_text()
|
|
}
|
|
|
|
fn path_hint_from_components(absolute: Option<&Path>, display: Option<&str>) -> Option<PathBuf> {
|
|
if let Some(abs) = absolute {
|
|
return Some(abs.to_path_buf());
|
|
}
|
|
display.map(PathBuf::from)
|
|
}
|
|
|
|
fn style_from_syntect(style: SynStyle) -> TuiStyle {
|
|
let mut tui_style = TuiStyle::default().fg(TuiColor::Rgb(
|
|
style.foreground.r,
|
|
style.foreground.g,
|
|
style.foreground.b,
|
|
));
|
|
|
|
let mut modifiers = Modifier::empty();
|
|
if style.font_style.contains(FontStyle::BOLD) {
|
|
modifiers |= Modifier::BOLD;
|
|
}
|
|
if style.font_style.contains(FontStyle::ITALIC) {
|
|
modifiers |= Modifier::ITALIC;
|
|
}
|
|
if style.font_style.contains(FontStyle::UNDERLINE) {
|
|
modifiers |= Modifier::UNDERLINED;
|
|
}
|
|
|
|
if !modifiers.is_empty() {
|
|
tui_style = tui_style.add_modifier(modifiers);
|
|
}
|
|
|
|
tui_style
|
|
}
|
|
|
|
pub fn build_highlighter(
|
|
absolute: Option<&Path>,
|
|
display: Option<&str>,
|
|
) -> HighlightLines<'static> {
|
|
let hint_path = path_hint_from_components(absolute, display);
|
|
let syntax = select_syntax(hint_path.as_deref());
|
|
HighlightLines::new(syntax, &THEME)
|
|
}
|
|
|
|
pub fn highlight_line(
|
|
highlighter: &mut HighlightLines<'static>,
|
|
line: &str,
|
|
) -> Vec<(TuiStyle, String)> {
|
|
let mut segments = Vec::new();
|
|
match highlighter.highlight_line(line, &SYNTAX_SET) {
|
|
Ok(result) => {
|
|
for (style, piece) in result {
|
|
let tui_style = style_from_syntect(style);
|
|
segments.push((tui_style, piece.to_string()));
|
|
}
|
|
}
|
|
Err(_) => {
|
|
segments.push((TuiStyle::default(), line.to_string()));
|
|
}
|
|
}
|
|
|
|
if segments.is_empty() {
|
|
segments.push((TuiStyle::default(), String::new()));
|
|
}
|
|
|
|
segments
|
|
}
|
|
|
|
pub fn build_highlighter_for_language(language: Option<&str>) -> HighlightLines<'static> {
|
|
let syntax = select_syntax_for_language(language);
|
|
HighlightLines::new(syntax, &THEME)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn rust_highlighting_produces_colored_segment() {
|
|
let mut highlighter = build_highlighter_for_language(Some("rust"));
|
|
let segments = highlight_line(&mut highlighter, "fn main() {}");
|
|
assert!(
|
|
segments
|
|
.iter()
|
|
.any(|(style, text)| style.fg.is_some() && !text.trim().is_empty()),
|
|
"Expected at least one colored segment"
|
|
);
|
|
}
|
|
}
|