feat(tui): enable syntax highlighting by default and refactor highlighting logic
- Set `default_syntax_highlighting` to true in core config. - Added language‑aware syntax selector (`select_syntax_for_language`) and highlighter builder (`build_highlighter_for_language`) with unit test. - Integrated new highlight module into `ChatApp`, using `UnicodeSegmentation` for proper grapheme handling. - Simplified `should_highlight_code` to always return true and removed extended‑color detection logic. - Reworked code rendering to use `inline_code_spans_from_text` and `wrap_highlight_segments` for accurate line wrapping and styling. - Cleaned up removed legacy keyword/comment parsing and extended‑color detection code.
This commit is contained in:
@@ -1389,7 +1389,7 @@ impl UiSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fn default_syntax_highlighting() -> bool {
|
const fn default_syntax_highlighting() -> bool {
|
||||||
false
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_show_timestamps() -> bool {
|
const fn default_show_timestamps() -> bool {
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ use tokio::{
|
|||||||
task::{self, JoinHandle},
|
task::{self, JoinHandle},
|
||||||
};
|
};
|
||||||
use tui_textarea::{CursorMove, Input, TextArea};
|
use tui_textarea::{CursorMove, Input, TextArea};
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::commands;
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::events::Event;
|
use crate::events::Event;
|
||||||
use crate::model_info_panel::ModelInfoPanel;
|
use crate::model_info_panel::ModelInfoPanel;
|
||||||
@@ -39,6 +39,7 @@ use crate::state::{
|
|||||||
};
|
};
|
||||||
use crate::toast::{Toast, ToastLevel, ToastManager};
|
use crate::toast::{Toast, ToastLevel, ToastManager};
|
||||||
use crate::ui::format_tool_output;
|
use crate::ui::format_tool_output;
|
||||||
|
use crate::{commands, highlight};
|
||||||
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
||||||
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
|
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
@@ -211,7 +212,6 @@ pub struct ChatApp {
|
|||||||
show_cursor_outside_insert: bool, // Configurable cursor visibility flag
|
show_cursor_outside_insert: bool, // Configurable cursor visibility flag
|
||||||
syntax_highlighting: bool, // Whether syntax highlighting is enabled
|
syntax_highlighting: bool, // Whether syntax highlighting is enabled
|
||||||
show_message_timestamps: bool, // Whether to render timestamps in chat headers
|
show_message_timestamps: bool, // Whether to render timestamps in chat headers
|
||||||
supports_extended_colors: bool, // Terminal supports 256-color output
|
|
||||||
auto_scroll: AutoScroll, // Auto-scroll state for message rendering
|
auto_scroll: AutoScroll, // Auto-scroll state for message rendering
|
||||||
thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel
|
thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel
|
||||||
viewport_height: usize, // Track the height of the messages viewport
|
viewport_height: usize, // Track the height of the messages viewport
|
||||||
@@ -516,7 +516,6 @@ impl ChatApp {
|
|||||||
new_message_alert: false,
|
new_message_alert: false,
|
||||||
show_cursor_outside_insert,
|
show_cursor_outside_insert,
|
||||||
syntax_highlighting,
|
syntax_highlighting,
|
||||||
supports_extended_colors: detect_extended_color_support(),
|
|
||||||
show_message_timestamps: show_timestamps,
|
show_message_timestamps: show_timestamps,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1910,7 +1909,7 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn should_highlight_code(&self) -> bool {
|
pub fn should_highlight_code(&self) -> bool {
|
||||||
self.syntax_highlighting && self.supports_extended_colors
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn render_message_lines_cached(
|
pub(crate) fn render_message_lines_cached(
|
||||||
@@ -2019,12 +2018,16 @@ impl ChatApp {
|
|||||||
chunks.push(String::new());
|
chunks.push(String::new());
|
||||||
}
|
}
|
||||||
for chunk in chunks {
|
for chunk in chunks {
|
||||||
let text = if indent.is_empty() {
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||||
chunk.clone()
|
if !indent.is_empty() {
|
||||||
} else {
|
spans.push(Span::styled(indent.to_string(), content_style));
|
||||||
format!("{indent}{chunk}")
|
}
|
||||||
};
|
|
||||||
rendered.push(Line::from(vec![Span::styled(text, content_style)]));
|
let inline_spans =
|
||||||
|
inline_code_spans_from_text(&chunk, theme, content_style);
|
||||||
|
spans.extend(inline_spans);
|
||||||
|
|
||||||
|
rendered.push(Line::from(spans));
|
||||||
*indicator_target = Some(rendered.len() - 1);
|
*indicator_target = Some(rendered.len() - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8334,16 +8337,6 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_extended_color_support() -> bool {
|
|
||||||
let term = std::env::var("TERM").unwrap_or_default();
|
|
||||||
if term.contains("256") || term.contains("direct") || term.contains("truecolor") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let colorterm = std::env::var("COLORTERM").unwrap_or_default();
|
|
||||||
colorterm.contains("24bit") || colorterm.contains("truecolor")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn role_label_parts(role: &Role) -> (&'static str, &'static str) {
|
pub(crate) fn role_label_parts(role: &Role) -> (&'static str, &'static str) {
|
||||||
match role {
|
match role {
|
||||||
Role::User => ("👤", "You"),
|
Role::User => ("👤", "You"),
|
||||||
@@ -8447,312 +8440,124 @@ fn wrap_code(text: &str, width: usize) -> Vec<String> {
|
|||||||
wrapped
|
wrapped
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
fn wrap_highlight_segments(
|
||||||
enum CommentMarker {
|
segments: Vec<(Style, String)>,
|
||||||
DoubleSlash,
|
code_width: usize,
|
||||||
Hash,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
enum CodeState {
|
|
||||||
Normal,
|
|
||||||
String { delimiter: char, escaped: bool },
|
|
||||||
}
|
|
||||||
|
|
||||||
const RUST_KEYWORDS: &[&str] = &[
|
|
||||||
"as", "async", "await", "break", "const", "crate", "dyn", "else", "enum", "extern", "false",
|
|
||||||
"fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
|
|
||||||
"return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
|
|
||||||
"where", "while",
|
|
||||||
];
|
|
||||||
|
|
||||||
const PYTHON_KEYWORDS: &[&str] = &[
|
|
||||||
"and", "as", "assert", "break", "class", "continue", "def", "del", "elif", "else", "except",
|
|
||||||
"false", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "none",
|
|
||||||
"nonlocal", "not", "or", "pass", "raise", "return", "true", "try", "while", "with", "yield",
|
|
||||||
];
|
|
||||||
|
|
||||||
const JS_KEYWORDS: &[&str] = &[
|
|
||||||
"async",
|
|
||||||
"await",
|
|
||||||
"break",
|
|
||||||
"case",
|
|
||||||
"catch",
|
|
||||||
"class",
|
|
||||||
"const",
|
|
||||||
"continue",
|
|
||||||
"debugger",
|
|
||||||
"default",
|
|
||||||
"delete",
|
|
||||||
"do",
|
|
||||||
"else",
|
|
||||||
"export",
|
|
||||||
"extends",
|
|
||||||
"finally",
|
|
||||||
"for",
|
|
||||||
"function",
|
|
||||||
"if",
|
|
||||||
"import",
|
|
||||||
"in",
|
|
||||||
"instanceof",
|
|
||||||
"let",
|
|
||||||
"new",
|
|
||||||
"return",
|
|
||||||
"switch",
|
|
||||||
"this",
|
|
||||||
"throw",
|
|
||||||
"try",
|
|
||||||
"typeof",
|
|
||||||
"var",
|
|
||||||
"void",
|
|
||||||
"while",
|
|
||||||
"with",
|
|
||||||
"yield",
|
|
||||||
];
|
|
||||||
|
|
||||||
const GO_KEYWORDS: &[&str] = &[
|
|
||||||
"break",
|
|
||||||
"case",
|
|
||||||
"chan",
|
|
||||||
"const",
|
|
||||||
"continue",
|
|
||||||
"default",
|
|
||||||
"defer",
|
|
||||||
"else",
|
|
||||||
"fallthrough",
|
|
||||||
"for",
|
|
||||||
"func",
|
|
||||||
"go",
|
|
||||||
"goto",
|
|
||||||
"if",
|
|
||||||
"import",
|
|
||||||
"interface",
|
|
||||||
"map",
|
|
||||||
"package",
|
|
||||||
"range",
|
|
||||||
"return",
|
|
||||||
"select",
|
|
||||||
"struct",
|
|
||||||
"switch",
|
|
||||||
"type",
|
|
||||||
"var",
|
|
||||||
];
|
|
||||||
|
|
||||||
const C_KEYWORDS: &[&str] = &[
|
|
||||||
"auto", "break", "case", "char", "const", "continue", "default", "do", "double", "else",
|
|
||||||
"enum", "extern", "float", "for", "goto", "if", "inline", "int", "long", "register",
|
|
||||||
"restrict", "return", "short", "signed", "sizeof", "static", "struct", "switch", "typedef",
|
|
||||||
"union", "unsigned", "void", "volatile", "while",
|
|
||||||
];
|
|
||||||
|
|
||||||
const BASH_KEYWORDS: &[&str] = &[
|
|
||||||
"case", "do", "done", "elif", "else", "esac", "fi", "for", "function", "if", "in", "select",
|
|
||||||
"then", "until", "while",
|
|
||||||
];
|
|
||||||
|
|
||||||
const JSON_KEYWORDS: &[&str] = &["false", "null", "true"];
|
|
||||||
|
|
||||||
const YAML_KEYWORDS: &[&str] = &["false", "no", "null", "true", "yes"];
|
|
||||||
|
|
||||||
const TOML_KEYWORDS: &[&str] = &["false", "inf", "nan", "true"];
|
|
||||||
|
|
||||||
fn keyword_list(language: &str) -> Option<&'static [&'static str]> {
|
|
||||||
match language {
|
|
||||||
"rust" | "rs" => Some(RUST_KEYWORDS),
|
|
||||||
"python" | "py" => Some(PYTHON_KEYWORDS),
|
|
||||||
"javascript" | "js" => Some(JS_KEYWORDS),
|
|
||||||
"typescript" | "ts" => Some(JS_KEYWORDS),
|
|
||||||
"go" | "golang" => Some(GO_KEYWORDS),
|
|
||||||
"c" | "cpp" | "c++" => Some(C_KEYWORDS),
|
|
||||||
"bash" | "sh" | "shell" => Some(BASH_KEYWORDS),
|
|
||||||
"json" => Some(JSON_KEYWORDS),
|
|
||||||
"yaml" | "yml" => Some(YAML_KEYWORDS),
|
|
||||||
"toml" => Some(TOML_KEYWORDS),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn comment_marker(language: &str) -> Option<CommentMarker> {
|
|
||||||
match language {
|
|
||||||
"rust" | "rs" | "javascript" | "js" | "typescript" | "ts" | "go" | "golang" | "c"
|
|
||||||
| "cpp" | "c++" | "java" => Some(CommentMarker::DoubleSlash),
|
|
||||||
"python" | "py" | "bash" | "sh" | "shell" | "yaml" | "yml" | "toml" => {
|
|
||||||
Some(CommentMarker::Hash)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush_normal_buffer(
|
|
||||||
buffer: &mut String,
|
|
||||||
keywords: Option<&[&str]>,
|
|
||||||
spans: &mut Vec<Span<'static>>,
|
|
||||||
base_style: Style,
|
|
||||||
keyword_style: Style,
|
|
||||||
) {
|
|
||||||
if buffer.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(keys) = keywords {
|
|
||||||
let mut token = String::new();
|
|
||||||
|
|
||||||
for ch in buffer.chars() {
|
|
||||||
if ch.is_alphanumeric() || ch == '_' {
|
|
||||||
token.push(ch);
|
|
||||||
} else {
|
|
||||||
if !token.is_empty() {
|
|
||||||
let lower = token.to_ascii_lowercase();
|
|
||||||
let style = if keys.binary_search(&lower.as_str()).is_ok() {
|
|
||||||
keyword_style
|
|
||||||
} else {
|
|
||||||
base_style
|
|
||||||
};
|
|
||||||
spans.push(Span::styled(token.clone(), style));
|
|
||||||
token.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut punct = String::new();
|
|
||||||
punct.push(ch);
|
|
||||||
spans.push(Span::styled(punct, base_style));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !token.is_empty() {
|
|
||||||
let lower = token.to_ascii_lowercase();
|
|
||||||
let style = if keys.binary_search(&lower.as_str()).is_ok() {
|
|
||||||
keyword_style
|
|
||||||
} else {
|
|
||||||
base_style
|
|
||||||
};
|
|
||||||
spans.push(Span::styled(token.clone(), style));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
spans.push(Span::styled(buffer.clone(), base_style));
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn highlight_code_spans(
|
|
||||||
chunk: &str,
|
|
||||||
language: Option<&str>,
|
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
syntax_highlighting: bool,
|
) -> Vec<Vec<(Style, String)>> {
|
||||||
) -> Vec<Span<'static>> {
|
let mut rows: Vec<Vec<(Style, String)>> = Vec::new();
|
||||||
let base_style = Style::default()
|
let mut current: Vec<(Style, String)> = Vec::new();
|
||||||
.fg(theme.code_block_text)
|
let mut current_width: usize = 0;
|
||||||
.bg(theme.code_block_background);
|
|
||||||
|
|
||||||
if !syntax_highlighting {
|
let push_row = |rows: &mut Vec<Vec<(Style, String)>>,
|
||||||
return vec![Span::styled(chunk.to_string(), base_style)];
|
current: &mut Vec<(Style, String)>,
|
||||||
|
current_width: &mut usize| {
|
||||||
|
rows.push(std::mem::take(current));
|
||||||
|
*current_width = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (style_raw, text) in segments {
|
||||||
|
let mut remaining = text.as_str();
|
||||||
|
if remaining.is_empty() {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalized = language.map(|lang| lang.trim().to_ascii_lowercase());
|
while !remaining.is_empty() {
|
||||||
let lang_ref = normalized.as_deref();
|
if current_width >= code_width {
|
||||||
let keywords = lang_ref.and_then(keyword_list);
|
push_row(&mut rows, &mut current, &mut current_width);
|
||||||
let comment = lang_ref.and_then(comment_marker);
|
}
|
||||||
|
|
||||||
let keyword_style = Style::default()
|
let available = code_width.saturating_sub(current_width);
|
||||||
.fg(theme.code_block_keyword)
|
if available == 0 {
|
||||||
|
push_row(&mut rows, &mut current, &mut current_width);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut take_bytes = 0;
|
||||||
|
let mut take_width = 0;
|
||||||
|
|
||||||
|
for grapheme in remaining.graphemes(true) {
|
||||||
|
let grapheme_width = UnicodeWidthStr::width(grapheme);
|
||||||
|
if take_width + grapheme_width > available {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
take_bytes += grapheme.len();
|
||||||
|
take_width += grapheme_width;
|
||||||
|
if take_width == available {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if take_bytes == 0 {
|
||||||
|
push_row(&mut rows, &mut current, &mut current_width);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk = &remaining[..take_bytes];
|
||||||
|
remaining = &remaining[take_bytes..];
|
||||||
|
|
||||||
|
let mut style = style_raw;
|
||||||
|
if style.fg.is_none() {
|
||||||
|
style = style.fg(theme.code_block_text);
|
||||||
|
}
|
||||||
|
style = style.bg(theme.code_block_background);
|
||||||
|
|
||||||
|
current.push((style, chunk.to_string()));
|
||||||
|
current_width += take_width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !current.is_empty() {
|
||||||
|
rows.push(current);
|
||||||
|
} else if rows.is_empty() {
|
||||||
|
rows.push(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inline_code_spans_from_text(text: &str, theme: &Theme, base_style: Style) -> Vec<Span<'static>> {
|
||||||
|
let tick_count = text.matches('`').count();
|
||||||
|
if tick_count < 2 || (tick_count & 1) != 0 {
|
||||||
|
return vec![Span::styled(text.to_string(), base_style)];
|
||||||
|
}
|
||||||
|
|
||||||
|
let code_style = Style::default()
|
||||||
|
.fg(theme.code_block_text)
|
||||||
.bg(theme.code_block_background)
|
.bg(theme.code_block_background)
|
||||||
.add_modifier(Modifier::BOLD);
|
.add_modifier(Modifier::BOLD);
|
||||||
let string_style = Style::default()
|
|
||||||
.fg(theme.code_block_string)
|
|
||||||
.bg(theme.code_block_background);
|
|
||||||
let comment_style = Style::default()
|
|
||||||
.fg(theme.code_block_comment)
|
|
||||||
.bg(theme.code_block_background)
|
|
||||||
.add_modifier(Modifier::ITALIC);
|
|
||||||
|
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let chars: Vec<char> = chunk.chars().collect();
|
let mut in_code = false;
|
||||||
let mut idx = 0;
|
|
||||||
let mut state = CodeState::Normal;
|
|
||||||
|
|
||||||
while idx < chars.len() {
|
for ch in text.chars() {
|
||||||
match state {
|
if ch == '`' {
|
||||||
CodeState::Normal => {
|
if in_code {
|
||||||
if let Some(marker) = comment {
|
if !buffer.is_empty() {
|
||||||
let is_comment = match marker {
|
spans.push(Span::styled(buffer.clone(), code_style));
|
||||||
CommentMarker::DoubleSlash => {
|
buffer.clear();
|
||||||
chars[idx] == '/' && idx + 1 < chars.len() && chars[idx + 1] == '/'
|
|
||||||
}
|
}
|
||||||
CommentMarker::Hash => chars[idx] == '#',
|
} else if !buffer.is_empty() {
|
||||||
};
|
spans.push(Span::styled(buffer.clone(), base_style));
|
||||||
|
buffer.clear();
|
||||||
if is_comment {
|
|
||||||
flush_normal_buffer(
|
|
||||||
&mut buffer,
|
|
||||||
keywords,
|
|
||||||
&mut spans,
|
|
||||||
base_style,
|
|
||||||
keyword_style,
|
|
||||||
);
|
|
||||||
let comment_text: String = chars[idx..].iter().collect();
|
|
||||||
spans.push(Span::styled(comment_text, comment_style));
|
|
||||||
return spans;
|
|
||||||
}
|
}
|
||||||
}
|
in_code = !in_code;
|
||||||
|
|
||||||
let ch = chars[idx];
|
|
||||||
if ch == '"' || ch == '\'' {
|
|
||||||
flush_normal_buffer(
|
|
||||||
&mut buffer,
|
|
||||||
keywords,
|
|
||||||
&mut spans,
|
|
||||||
base_style,
|
|
||||||
keyword_style,
|
|
||||||
);
|
|
||||||
buffer.push(ch);
|
|
||||||
state = CodeState::String {
|
|
||||||
delimiter: ch,
|
|
||||||
escaped: false,
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
buffer.push(ch);
|
buffer.push(ch);
|
||||||
}
|
}
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
CodeState::String { delimiter, escaped } => {
|
|
||||||
let ch = chars[idx];
|
|
||||||
buffer.push(ch);
|
|
||||||
|
|
||||||
let mut next_state = CodeState::String {
|
|
||||||
delimiter,
|
|
||||||
escaped: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if escaped {
|
|
||||||
next_state = CodeState::String {
|
|
||||||
delimiter,
|
|
||||||
escaped: false,
|
|
||||||
};
|
|
||||||
} else if ch == '\\' {
|
|
||||||
next_state = CodeState::String {
|
|
||||||
delimiter,
|
|
||||||
escaped: true,
|
|
||||||
};
|
|
||||||
} else if ch == delimiter {
|
|
||||||
spans.push(Span::styled(buffer.clone(), string_style));
|
|
||||||
buffer.clear();
|
|
||||||
next_state = CodeState::Normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state = next_state;
|
if in_code {
|
||||||
idx += 1;
|
return vec![Span::styled(text.to_string(), base_style)];
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match state {
|
if !buffer.is_empty() {
|
||||||
CodeState::String { .. } => {
|
spans.push(Span::styled(buffer, base_style));
|
||||||
spans.push(Span::styled(buffer.clone(), string_style));
|
|
||||||
}
|
|
||||||
CodeState::Normal => {
|
|
||||||
flush_normal_buffer(&mut buffer, keywords, &mut spans, base_style, keyword_style);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if spans.is_empty() {
|
||||||
|
spans.push(Span::styled(String::new(), base_style));
|
||||||
}
|
}
|
||||||
|
|
||||||
spans
|
spans
|
||||||
@@ -8823,52 +8628,60 @@ fn append_code_block_lines(
|
|||||||
top_spans.push(Span::styled("╮", border_style));
|
top_spans.push(Span::styled("╮", border_style));
|
||||||
rendered.push(Line::from(top_spans));
|
rendered.push(Line::from(top_spans));
|
||||||
|
|
||||||
if code_lines.is_empty() {
|
let mut highlighter = if syntax_highlighting {
|
||||||
let chunks = wrap_code("", code_width);
|
Some(highlight::build_highlighter_for_language(language))
|
||||||
for chunk in chunks {
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut process_line = |line: &str| {
|
||||||
|
let segments = if let Some(highlighter) = highlighter.as_mut() {
|
||||||
|
let mut segments = highlight::highlight_line(highlighter, line);
|
||||||
|
if segments.is_empty() {
|
||||||
|
segments.push((Style::default(), String::new()));
|
||||||
|
}
|
||||||
|
segments
|
||||||
|
} else {
|
||||||
|
vec![(Style::default(), line.to_string())]
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_content = segments.iter().any(|(_, text)| !text.is_empty());
|
||||||
|
let rows = if has_content {
|
||||||
|
wrap_highlight_segments(segments, code_width, theme)
|
||||||
|
} else {
|
||||||
|
vec![Vec::new()]
|
||||||
|
};
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
spans.push(Span::styled(indent.to_string(), border_style));
|
spans.push(Span::styled(indent.to_string(), border_style));
|
||||||
spans.push(Span::styled("│", border_style));
|
spans.push(Span::styled("│", border_style));
|
||||||
|
|
||||||
let mut code_spans = highlight_code_spans(&chunk, language, theme, syntax_highlighting);
|
let mut row_width = 0;
|
||||||
spans.append(&mut code_spans);
|
if row.is_empty() {
|
||||||
|
spans.push(Span::styled(" ".repeat(code_width), text_style));
|
||||||
let display_width = UnicodeWidthStr::width(chunk.as_str());
|
} else {
|
||||||
if display_width < code_width {
|
for (style, piece) in row {
|
||||||
spans.push(Span::styled(
|
let width = UnicodeWidthStr::width(piece.as_str());
|
||||||
" ".repeat(code_width - display_width),
|
row_width += width;
|
||||||
text_style,
|
spans.push(Span::styled(piece, style));
|
||||||
));
|
}
|
||||||
|
if row_width < code_width {
|
||||||
|
spans.push(Span::styled(" ".repeat(code_width - row_width), text_style));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
spans.push(Span::styled("│", border_style));
|
spans.push(Span::styled("│", border_style));
|
||||||
rendered.push(Line::from(spans));
|
rendered.push(Line::from(spans));
|
||||||
*indicator_target = Some(rendered.len() - 1);
|
*indicator_target = Some(rendered.len() - 1);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if code_lines.is_empty() {
|
||||||
|
process_line("");
|
||||||
} else {
|
} else {
|
||||||
for line in code_lines {
|
for line in code_lines {
|
||||||
let chunks = wrap_code(line.as_str(), code_width);
|
process_line(line);
|
||||||
for chunk in chunks {
|
|
||||||
let mut spans = Vec::new();
|
|
||||||
spans.push(Span::styled(indent.to_string(), border_style));
|
|
||||||
spans.push(Span::styled("│", border_style));
|
|
||||||
|
|
||||||
let mut code_spans =
|
|
||||||
highlight_code_spans(&chunk, language, theme, syntax_highlighting);
|
|
||||||
spans.append(&mut code_spans);
|
|
||||||
|
|
||||||
let display_width = UnicodeWidthStr::width(chunk.as_str());
|
|
||||||
if display_width < code_width {
|
|
||||||
spans.push(Span::styled(
|
|
||||||
" ".repeat(code_width - display_width),
|
|
||||||
text_style,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
spans.push(Span::styled("│", border_style));
|
|
||||||
rendered.push(Line::from(spans));
|
|
||||||
*indicator_target = Some(rendered.len() - 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,42 @@ fn select_syntax(path_hint: Option<&Path>) -> &'static SyntaxReference {
|
|||||||
SYNTAX_SET.find_syntax_plain_text()
|
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> {
|
fn path_hint_from_components(absolute: Option<&Path>, display: Option<&str>) -> Option<PathBuf> {
|
||||||
if let Some(abs) = absolute {
|
if let Some(abs) = absolute {
|
||||||
return Some(abs.to_path_buf());
|
return Some(abs.to_path_buf());
|
||||||
@@ -100,3 +136,25 @@ pub fn highlight_line(
|
|||||||
|
|
||||||
segments
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user