syntec, but not compiling

This commit is contained in:
Priec
2025-08-19 00:46:11 +02:00
parent a3f578ebac
commit 3fdb7e4e37
9 changed files with 1262 additions and 27 deletions

View File

@@ -0,0 +1,183 @@
// src/textarea/highlight/chunks.rs
use ratatui::text::{Line, Span};
use ratatui::style::Style;
use unicode_width::UnicodeWidthChar;
#[derive(Debug, Clone)]
pub struct StyledChunk {
pub text: String,
pub style: Style,
}
pub fn display_width_chunks(chunks: &[StyledChunk]) -> u16 {
chunks
.iter()
.map(|c| {
c.text
.chars()
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
.sum::<u16>()
})
.sum()
}
pub fn slice_chunks_by_display_cols(
chunks: &[StyledChunk],
start_cols: u16,
max_cols: u16,
) -> Vec<StyledChunk> {
if max_cols == 0 {
return Vec::new();
}
let mut skipped: u16 = 0;
let mut taken: u16 = 0;
let mut out: Vec<StyledChunk> = Vec::new();
for ch in chunks {
if taken >= max_cols {
break;
}
let mut acc = String::new();
for c in ch.text.chars() {
let w = UnicodeWidthChar::width(c).unwrap_or(0) as u16;
if skipped + w <= start_cols {
skipped += w;
continue;
}
if taken + w > max_cols {
break;
}
acc.push(c);
taken = taken.saturating_add(w);
if taken >= max_cols {
break;
}
}
if !acc.is_empty() {
out.push(StyledChunk {
text: acc,
style: ch.style,
});
}
}
out
}
pub fn clip_chunks_window_with_indicator_padded(
chunks: &[StyledChunk],
view_width: u16,
indicator: char,
start_cols: u16,
) -> Line<'static> {
if view_width == 0 {
return Line::from("");
}
let total = display_width_chunks(chunks);
let show_left = start_cols > 0;
let left_cols: u16 = if show_left { 1 } else { 0 };
let cap_with_right = view_width.saturating_sub(left_cols + 1);
let remaining = total.saturating_sub(start_cols);
let show_right = remaining > cap_with_right;
let max_visible = if show_right {
cap_with_right
} else {
view_width.saturating_sub(left_cols)
};
let visible = slice_chunks_by_display_cols(chunks, start_cols, max_visible);
let used_cols = left_cols + display_width_chunks(&visible);
let mut spans: Vec<Span> = Vec::new();
if show_left {
spans.push(Span::raw(indicator.to_string()));
}
for v in visible {
spans.push(Span::styled(v.text, v.style));
}
if show_right {
let right_pos = view_width.saturating_sub(1);
let filler = right_pos.saturating_sub(used_cols);
if filler > 0 {
spans.push(Span::raw(" ".repeat(filler as usize)));
}
spans.push(Span::raw(indicator.to_string()));
}
Line::from(spans)
}
pub fn wrap_chunks_indented(
chunks: &[StyledChunk],
width: u16,
indent: u16,
) -> Vec<Line<'static>> {
if width == 0 {
return vec![Line::from("")];
}
let indent = indent.min(width.saturating_sub(1));
let cont_cap = width.saturating_sub(indent);
let indent_str = " ".repeat(indent as usize);
let mut lines: Vec<Line> = Vec::new();
let mut current_spans: Vec<Span> = Vec::new();
let mut used: u16 = 0;
let mut first_line = true;
// Fixed: Restructure to avoid borrow checker issues
for chunk in chunks {
let mut buf = String::new();
let mut buf_style = chunk.style;
for ch in chunk.text.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let cap = if first_line { width } else { cont_cap };
if used > 0 && used.saturating_add(w) >= cap {
if !buf.is_empty() {
current_spans.push(Span::styled(buf.clone(), buf_style));
buf.clear();
}
lines.push(Line::from(current_spans));
current_spans = Vec::new();
first_line = false;
used = 0;
// Add indent directly instead of using closure
if !first_line && indent > 0 {
current_spans.push(Span::raw(indent_str.clone()));
used = indent;
}
}
if !buf.is_empty() && buf_style != chunk.style {
current_spans.push(Span::styled(buf.clone(), buf_style));
buf.clear();
}
buf_style = chunk.style;
// Add indent if needed
if used == 0 && !first_line && indent > 0 {
current_spans.push(Span::raw(indent_str.clone()));
used = indent;
}
buf.push(ch);
used = used.saturating_add(w);
}
if !buf.is_empty() {
current_spans.push(Span::styled(buf, buf_style));
}
}
lines.push(Line::from(current_spans));
lines
}

View File

@@ -0,0 +1,269 @@
// src/textarea/highlight/engine.rs
#![allow(dead_code)]
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use ratatui::style::{Modifier, Style};
use syntect::{
highlighting::{
HighlightIterator, Highlighter, Style as SynStyle, Theme, ThemeSet,
},
parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet},
};
use crate::data_provider::DataProvider;
use super::chunks::StyledChunk;
#[derive(Debug)]
pub struct SyntectEngine {
ps: SyntaxSet,
ts: ThemeSet,
theme_name: String,
syntax_name: Option<String>,
// Cached parser state (after line i)
parse_after: Vec<ParseState>,
// Cached scope stack (after line i)
stack_after: Vec<ScopeStack>,
// Hash of line contents to detect edits
line_hashes: Vec<u64>,
}
impl SyntectEngine {
pub fn new() -> Self {
let ps = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();
Self {
ps,
ts,
theme_name: "InspiredGitHub".to_string(),
syntax_name: None,
parse_after: Vec::new(),
stack_after: Vec::new(),
line_hashes: Vec::new(),
}
}
pub fn clear(&mut self) {
self.parse_after.clear();
self.stack_after.clear();
self.line_hashes.clear();
}
pub fn set_theme(&mut self, theme_name: &str) -> bool {
if self.ts.themes.contains_key(theme_name) {
self.theme_name = theme_name.to_string();
true
} else {
false
}
}
pub fn set_syntax_by_name(&mut self, name: &str) -> bool {
if self.ps.find_syntax_by_name(name).is_some() {
self.syntax_name = Some(name.to_string());
self.clear();
true
} else {
false
}
}
pub fn set_syntax_by_extension(&mut self, ext: &str) -> bool {
if let Some(s) = self.ps.find_syntax_by_extension(ext) {
self.syntax_name = Some(s.name.clone());
self.clear();
true
} else {
false
}
}
pub fn invalidate_from(&mut self, line_idx: usize) {
if line_idx < self.parse_after.len() {
self.parse_after.truncate(line_idx);
}
if line_idx < self.stack_after.len() {
self.stack_after.truncate(line_idx);
}
if line_idx < self.line_hashes.len() {
self.line_hashes.truncate(line_idx);
}
}
pub fn on_insert_line(&mut self, at: usize) {
self.invalidate_from(at);
}
pub fn on_delete_line(&mut self, at: usize) {
self.invalidate_from(at);
}
fn theme(&self) -> &Theme {
self.ts
.themes
.get(&self.theme_name)
.expect("theme exists")
}
fn syntax_ref(&self) -> &SyntaxReference {
if let Some(name) = &self.syntax_name {
if let Some(s) = self.ps.find_syntax_by_name(name) {
return s;
}
}
self.ps.find_syntax_plain_text()
}
fn map_syntect_style(s: SynStyle) -> Style {
let fg =
ratatui::style::Color::Rgb(s.foreground.r, s.foreground.g, s.foreground.b);
let mut st = Style::default().fg(fg);
use syntect::highlighting::FontStyle;
if s.font_style.contains(FontStyle::BOLD) {
st = st.add_modifier(Modifier::BOLD);
}
if s.font_style.contains(FontStyle::UNDERLINE) {
st = st.add_modifier(Modifier::UNDERLINED);
}
if s.font_style.contains(FontStyle::ITALIC) {
st = st.add_modifier(Modifier::ITALIC);
}
st
}
fn hash_line(s: &str) -> u64 {
let mut h = DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
// Verify cached chain up to the nearest trusted predecessor of line_idx,
// using the provider to fetch the current lines.
fn verify_and_truncate_before(&mut self, line_idx: usize, provider: &dyn DataProvider) {
let mut k = std::cmp::min(line_idx, self.parse_after.len());
while k > 0 {
let j = k - 1;
let curr = Self::hash_line(provider.field_value(j));
if self.line_hashes.get(j) == Some(&curr) {
break;
}
self.invalidate_from(j);
k = j;
}
}
// Ensure we have parser + stack for lines [0..line_idx)
fn ensure_state_before(&mut self, line_idx: usize, provider: &dyn DataProvider) {
if line_idx == 0 || self.parse_after.len() >= line_idx {
return;
}
let syntax = self.syntax_ref();
let theme = self.theme();
let highlighter = Highlighter::new(theme);
let mut ps = if self.parse_after.is_empty() {
ParseState::new(syntax)
} else {
self.parse_after[self.parse_after.len() - 1].clone()
};
let mut stack = if self.stack_after.is_empty() {
ScopeStack::new()
} else {
self.stack_after[self.stack_after.len() - 1].clone()
};
let start = self.parse_after.len();
for i in start..line_idx {
let s = provider.field_value(i);
let ops = ps.parse_line(s);
// Advance stack by applying ops using HighlightIterator
let mut it = HighlightIterator::new(&highlighter, &ops[..], s, &mut stack);
while let Some((_style, _text)) = it.next() {
// Iterate to apply ops; we don't need the tokens here.
}
let h = Self::hash_line(s);
self.parse_after.push(ps.clone());
self.stack_after.push(stack.clone());
if i >= self.line_hashes.len() {
self.line_hashes.push(h);
} else {
self.line_hashes[i] = h;
}
}
}
// Highlight a single line using cached state; update caches for this line.
pub fn highlight_line_cached(
&mut self,
line_idx: usize,
line: &str,
provider: &dyn DataProvider,
) -> Vec<StyledChunk> {
// Auto-detect prior changes and truncate cache if needed
self.verify_and_truncate_before(line_idx, provider);
// Precompute states up to line_idx
self.ensure_state_before(line_idx, provider);
let syntax = self.syntax_ref();
let theme = self.theme();
let highlighter = Highlighter::new(theme);
let mut ps = if line_idx == 0 {
ParseState::new(syntax)
} else if self.parse_after.len() >= line_idx {
self.parse_after[line_idx - 1].clone()
} else {
ParseState::new(syntax)
};
let mut stack = if line_idx == 0 {
ScopeStack::new()
} else if self.stack_after.len() >= line_idx {
self.stack_after[line_idx - 1].clone()
} else {
ScopeStack::new()
};
let ops = ps.parse_line(line);
let mut iter = HighlightIterator::new(&highlighter, &ops[..], line, &mut stack);
let mut out: Vec<StyledChunk> = Vec::new();
while let Some((syn_style, slice)) = iter.next() {
if slice.is_empty() {
continue;
}
let text = slice.trim_end_matches('\n').to_string();
if text.is_empty() {
continue;
}
out.push(StyledChunk {
text,
style: Self::map_syntect_style(syn_style),
});
}
// Update caches for this line (state after this line)
let h = Self::hash_line(line);
if line_idx >= self.parse_after.len() {
self.parse_after.push(ps);
} else {
self.parse_after[line_idx] = ps;
}
if line_idx >= self.stack_after.len() {
self.stack_after.push(stack);
} else {
self.stack_after[line_idx] = stack;
}
if line_idx >= self.line_hashes.len() {
self.line_hashes.push(h);
} else {
self.line_hashes[line_idx] = h;
}
out
}
}

View File

@@ -0,0 +1,18 @@
// src/textarea/highlight/mod.rs
#[cfg(all(feature = "syntect", feature = "gui"))]
pub mod engine;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub mod chunks;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub mod state;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub mod widget;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub use engine::SyntectEngine;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub use chunks::StyledChunk;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub use state::TextAreaSyntaxState;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub use widget::TextAreaSyntax;

View File

@@ -0,0 +1,53 @@
// src/textarea/highlight/state.rs
use std::ops::{Deref, DerefMut};
use super::engine::SyntectEngine;
use crate::textarea::state::TextAreaState;
// Remove Debug derive since TextAreaState doesn't implement Debug
pub struct TextAreaSyntaxState {
pub textarea: TextAreaState,
pub engine: SyntectEngine,
}
impl Default for TextAreaSyntaxState {
fn default() -> Self {
Self {
textarea: TextAreaState::default(),
engine: SyntectEngine::new(),
}
}
}
impl TextAreaSyntaxState {
pub fn from_text<S: Into<String>>(text: S) -> Self {
let mut s = Self::default();
s.textarea.set_text(text);
s
}
// Optional: convenience setters
pub fn set_syntax_theme(&mut self, theme: &str) -> bool {
self.engine.set_theme(theme)
}
pub fn set_syntax_by_name(&mut self, name: &str) -> bool {
self.engine.set_syntax_by_name(name)
}
pub fn set_syntax_by_extension(&mut self, ext: &str) -> bool {
self.engine.set_syntax_by_extension(ext)
}
}
impl Deref for TextAreaSyntaxState {
type Target = TextAreaState;
fn deref(&self) -> &Self::Target {
&self.textarea
}
}
impl DerefMut for TextAreaSyntaxState {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.textarea
}
}

View File

@@ -0,0 +1,211 @@
// src/textarea/highlight/widget.rs
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::Style,
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, StatefulWidget, Widget},
};
use unicode_width::UnicodeWidthChar;
use super::chunks::{
clip_chunks_window_with_indicator_padded,
wrap_chunks_indented,
};
use super::state::TextAreaSyntaxState;
use crate::data_provider::DataProvider;
use crate::textarea::state::{
compute_h_scroll_with_padding, count_wrapped_rows_indented, TextOverflowMode,
};
#[derive(Debug, Clone)]
pub struct TextAreaSyntax<'a> {
pub block: Option<Block<'a>>,
pub style: Style,
pub border_type: BorderType,
}
impl<'a> Default for TextAreaSyntax<'a> {
fn default() -> Self {
Self {
block: Some(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
),
style: Style::default(),
border_type: BorderType::Rounded,
}
}
}
impl<'a> TextAreaSyntax<'a> {
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn border_type(mut self, ty: BorderType) -> Self {
self.border_type = ty;
if let Some(b) = &mut self.block {
*b = b.clone().border_type(ty);
}
self
}
}
fn display_width(s: &str) -> u16 {
s.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0) as u16)
.sum()
}
fn display_cols_up_to(s: &str, char_count: usize) -> u16 {
let mut cols: u16 = 0;
for (i, ch) in s.chars().enumerate() {
if i >= char_count {
break;
}
cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
cols
}
fn resolve_start_line_and_intra_indented(
state: &TextAreaSyntaxState,
inner: Rect,
) -> (usize, u16) {
let provider = state.textarea.editor.data_provider();
let total = provider.line_count();
if total == 0 {
return (0, 0);
}
let wrap = matches!(state.textarea.overflow_mode, TextOverflowMode::Wrap);
let width = inner.width;
let target_vis = state.textarea.scroll_y;
if !wrap {
let start = (target_vis as usize).min(total);
return (start, 0);
}
let indent = state.textarea.wrap_indent_cols;
let mut acc: u16 = 0;
for i in 0..total {
let s = provider.field_value(i);
let rows = count_wrapped_rows_indented(s, width, indent);
if acc.saturating_add(rows) > target_vis {
let intra = target_vis.saturating_sub(acc);
return (i, intra);
}
acc = acc.saturating_add(rows);
}
(total.saturating_sub(1), 0)
}
impl<'a> StatefulWidget for TextAreaSyntax<'a> {
type State = TextAreaSyntaxState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
// Reuse existing scroll logic
state.textarea.ensure_visible(area, self.block.as_ref());
let inner = if let Some(b) = &self.block {
b.clone().render(area, buf);
b.inner(area)
} else {
area
};
let edited_now = state.textarea.take_edited_flag();
let wrap_mode = matches!(state.textarea.overflow_mode, TextOverflowMode::Wrap);
let provider = state.textarea.editor.data_provider();
let total = provider.line_count();
let (start, intra) = resolve_start_line_and_intra_indented(state, inner);
let mut display_lines: Vec<Line> = Vec::new();
if total == 0 || start >= total {
if let Some(ph) = &state.textarea.placeholder {
display_lines.push(Line::from(Span::raw(ph.clone())));
}
} else if wrap_mode {
let mut rows_left = inner.height;
let indent = state.textarea.wrap_indent_cols;
let mut i = start;
while i < total && rows_left > 0 {
let s = provider.field_value(i);
let chunks = state
.engine
.highlight_line_cached(i, s, provider);
let lines = wrap_chunks_indented(&chunks, inner.width, indent);
let skip = if i == start { intra as usize } else { 0 };
for l in lines.into_iter().skip(skip) {
display_lines.push(l);
rows_left = rows_left.saturating_sub(1);
if rows_left == 0 {
break;
}
}
i += 1;
}
} else {
let end = (start.saturating_add(inner.height as usize)).min(total);
for i in start..end {
let s = provider.field_value(i);
let chunks = state.engine.highlight_line_cached(i, s, provider);
let fits = display_width(s) <= inner.width;
let start_cols = if i == state.textarea.current_field() {
let col_idx = state.textarea.display_cursor_position();
let cursor_cols = display_cols_up_to(s, col_idx);
let (target_h, _left_cols) =
compute_h_scroll_with_padding(cursor_cols, inner.width);
if fits {
if edited_now {
target_h
} else {
0
}
} else {
target_h.max(state.textarea.h_scroll)
}
} else {
0
};
if let TextOverflowMode::Indicator { ch } = state.textarea.overflow_mode {
display_lines.push(clip_chunks_window_with_indicator_padded(
&chunks,
inner.width,
ch,
start_cols,
));
}
}
}
let p = Paragraph::new(display_lines)
.alignment(Alignment::Left)
.style(self.style);
p.render(inner, buf);
}
}

View File

@@ -1,8 +1,5 @@
// src/textarea/mod.rs
//! Text area convenience exports.
//!
//! Re-export the core textarea types and provider so consumers can use
//! `canvas::textarea::TextArea` / `TextAreaState` / `TextAreaProvider`.
pub mod provider;
pub mod state;
@@ -10,6 +7,9 @@ pub mod state;
#[cfg(feature = "gui")]
pub mod widget;
#[cfg(all(feature = "syntect", feature = "gui"))]
pub mod highlight;
pub use provider::TextAreaProvider;
pub use state::{TextAreaEditor, TextAreaState, TextOverflowMode};