Acord/viewport/src/editor.rs

514 lines
18 KiB
Rust

use std::sync::Arc;
use iced_wgpu::core::keyboard;
use iced_wgpu::core::keyboard::key;
use iced_wgpu::core::text::{Highlight, Wrapping};
use iced_wgpu::core::{
border, padding, Background, Border, Color, Element, Font, Length, Padding, Shadow, Theme,
};
use iced_widget::container;
use iced_widget::markdown;
use iced_widget::text_editor::{self, Binding, KeyPress, Motion, Status, Style};
use iced_wgpu::core::text::highlighter::Format;
use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings};
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Message {
EditorAction(text_editor::Action),
TogglePreview,
MarkdownLink(markdown::Uri),
InsertTable,
ToggleBold,
ToggleItalic,
Evaluate,
SmartEval,
ZoomIn,
ZoomOut,
ZoomReset,
}
pub struct EditorState {
pub content: text_editor::Content<iced_wgpu::Renderer>,
pub font_size: f32,
pub preview: bool,
pub parsed: Vec<markdown::Item>,
pub eval_results: Vec<(usize, String)>,
pub eval_errors: Vec<(usize, String)>,
pub lang: Option<String>,
}
fn md_style() -> markdown::Style {
markdown::Style {
font: Font::default(),
inline_code_highlight: Highlight {
background: Color::from_rgb(0.188, 0.188, 0.259).into(),
border: border::rounded(4),
},
inline_code_padding: padding::left(2).right(2),
inline_code_color: Color::from_rgb(0.651, 0.890, 0.631),
inline_code_font: Font::MONOSPACE,
code_block_font: Font::MONOSPACE,
link_color: Color::from_rgb(0.537, 0.706, 0.980),
}
}
impl EditorState {
pub fn new() -> Self {
let sample = concat!(
"use std::collections::HashMap;\n\n",
"/// A simple key-value store.\n",
"pub struct Store {\n",
" data: HashMap<String, i64>,\n",
"}\n\n",
"impl Store {\n",
" pub fn new() -> Self {\n",
" Self { data: HashMap::new() }\n",
" }\n\n",
" pub fn insert(&mut self, key: &str, value: i64) {\n",
" self.data.insert(key.to_string(), value);\n",
" }\n\n",
" pub fn get(&self, key: &str) -> Option<&i64> {\n",
" self.data.get(key)\n",
" }\n",
"}\n\n",
"fn main() {\n",
" let mut store = Store::new();\n",
" store.insert(\"count\", 42);\n",
" if let Some(val) = store.get(\"count\") {\n",
" println!(\"value: {val}\");\n",
" }\n",
"}\n",
);
Self {
content: text_editor::Content::with_text(sample),
font_size: 14.0,
preview: false,
parsed: Vec::new(),
eval_results: Vec::new(),
eval_errors: Vec::new(),
lang: Some("rust".into()),
}
}
pub fn set_text(&mut self, text: &str) {
self.content = text_editor::Content::with_text(text);
self.reparse();
}
pub fn set_lang_from_ext(&mut self, ext: &str) {
self.lang = lang_from_extension(ext);
}
fn reparse(&mut self) {
let text = self.content.text();
self.parsed = markdown::parse(&text).collect();
}
fn toggle_wrap(&mut self, marker: &str) {
let mlen = marker.len();
match self.content.selection() {
Some(sel) if sel.starts_with(marker) && sel.ends_with(marker) && sel.len() >= mlen * 2 => {
let inner = &sel[mlen..sel.len() - mlen];
self.content.perform(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(inner.to_string())),
));
}
Some(sel) => {
let wrapped = format!("{marker}{sel}{marker}");
self.content.perform(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(wrapped)),
));
}
None => {
let empty = format!("{marker}{marker}");
self.content.perform(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(empty)),
));
for _ in 0..mlen {
self.content.perform(text_editor::Action::Move(Motion::Left));
}
}
}
self.reparse();
}
fn run_eval(&mut self) {
let text = self.content.text();
let doc = crate::eval::evaluate_document(&text);
self.eval_results = doc.results.into_iter().map(|r| (r.line, r.result)).collect();
self.eval_errors = doc.errors.into_iter().map(|e| (e.line, e.error)).collect();
}
pub fn update(&mut self, message: Message) {
match message {
Message::EditorAction(action) => {
let is_edit = action.is_edit();
let auto_indent = if let text_editor::Action::Edit(text_editor::Edit::Enter) = &action {
let cursor = self.content.cursor();
let line_text = self.content.line(cursor.position.line)
.map(|l| l.text.to_string())
.unwrap_or_default();
let base = leading_whitespace(&line_text).to_string();
let trimmed = line_text.trim_end();
let extra = matches!(trimmed.as_bytes().last(), Some(b'{' | b'[' | b'('));
if extra {
Some(format!("{base} "))
} else {
Some(base)
}
} else {
None
};
let dedent = if let text_editor::Action::Edit(text_editor::Edit::Insert(ch)) = &action {
matches!(ch, '}' | ']' | ')').then(|| {
let cursor = self.content.cursor();
let line_text = self.content.line(cursor.position.line)
.map(|l| l.text.to_string())
.unwrap_or_default();
let prefix = &line_text[..cursor.position.column];
if prefix.chars().all(|c| c == ' ' || c == '\t') && prefix.len() >= 4 {
Some(prefix.len())
} else {
None
}
}).flatten()
} else {
None
};
self.content.perform(action);
if let Some(indent) = auto_indent {
if !indent.is_empty() {
self.content.perform(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(indent)),
));
}
}
if let Some(col) = dedent {
let remove = col.min(4);
self.content.perform(text_editor::Action::Move(Motion::Left));
for _ in 0..remove {
self.content.perform(text_editor::Action::Edit(
text_editor::Edit::Backspace,
));
}
self.content.perform(text_editor::Action::Move(Motion::Right));
}
if is_edit {
if self.lang.is_none() {
self.lang = detect_lang_from_content(&self.content.text());
}
self.reparse();
self.run_eval();
}
}
Message::InsertTable => {
let table = "| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| | | |\n| | | |\n";
self.content.perform(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(table.to_string())),
));
self.reparse();
self.run_eval();
}
Message::ToggleBold => {
self.toggle_wrap("**");
}
Message::ToggleItalic => {
self.toggle_wrap("*");
}
Message::Evaluate => {
self.run_eval();
}
Message::SmartEval => {
let cursor = self.content.cursor();
let text = self.content.text();
let lines: Vec<&str> = text.lines().collect();
let line_idx = cursor.position.line;
if line_idx < lines.len() {
let line = lines[line_idx].trim();
if let Some(varname) = parse_let_binding(line) {
let insert = format!("\n/= {varname}");
self.content.perform(text_editor::Action::Move(Motion::End));
self.content.perform(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(insert)),
));
self.reparse();
self.run_eval();
}
}
}
Message::TogglePreview => {
self.preview = !self.preview;
if self.preview {
self.reparse();
}
}
Message::MarkdownLink(_url) => {}
Message::ZoomIn => {
self.font_size = (self.font_size + 1.0).min(48.0);
}
Message::ZoomOut => {
self.font_size = (self.font_size - 1.0).max(8.0);
}
Message::ZoomReset => {
self.font_size = 14.0;
}
}
}
pub fn view(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let main_content: Element<'_, Message, Theme, iced_wgpu::Renderer> = if self.preview {
let settings = markdown::Settings::with_text_size(self.font_size, md_style());
let preview = markdown::view(&self.parsed, settings)
.map(Message::MarkdownLink);
iced_widget::container(
iced_widget::scrollable(
iced_widget::container(preview)
.padding(Padding { top: 38.0, right: 16.0, bottom: 16.0, left: 16.0 })
)
.height(Length::Fill)
)
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.08, 0.08, 0.10))),
border: Border::default(),
text_color: Some(Color::from_rgb(0.804, 0.839, 0.957)),
shadow: Shadow::default(),
snap: false,
})
.into()
} else {
let editor = iced_widget::text_editor(&self.content)
.on_action(Message::EditorAction)
.font(Font::MONOSPACE)
.size(self.font_size)
.height(Length::Fill)
.padding(Padding { top: 38.0, right: 8.0, bottom: 8.0, left: 8.0 })
.wrapping(Wrapping::Word)
.key_binding(macos_key_binding)
.style(|_theme, _status| Style {
background: Background::Color(Color::from_rgb(0.08, 0.08, 0.10)),
border: Border::default(),
placeholder: Color::from_rgb(0.4, 0.4, 0.4),
value: Color::WHITE,
selection: Color::from_rgba(0.3, 0.5, 0.8, 0.4),
});
if let Some(lang) = &self.lang {
let settings = SyntaxSettings {
lang: lang.clone(),
source: self.content.text(),
};
editor
.highlight_with::<SyntaxHighlighter>(
settings,
|highlight, _theme| Format {
color: Some(syntax::highlight_color(highlight.kind)),
font: None,
},
)
.into()
} else {
editor.into()
}
};
let mode_label = if self.preview { "Preview" } else { "Edit" };
let cursor = self.content.cursor();
let line = cursor.position.line + 1;
let col = cursor.position.column + 1;
let status_bar = iced_widget::container(
iced_widget::row([
iced_widget::text(format!("{mode_label} Ln {line}, Col {col}"))
.font(Font::MONOSPACE)
.size(11.0)
.color(Color::from_rgb(0.55, 0.55, 0.55))
.into(),
])
)
.width(Length::Fill)
.padding(Padding { top: 3.0, right: 10.0, bottom: 3.0, left: 10.0 })
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.12, 0.12, 0.14))),
border: Border::default(),
text_color: None,
shadow: Shadow::default(),
snap: false,
});
let mut col_items: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> =
vec![main_content];
if !self.eval_results.is_empty() || !self.eval_errors.is_empty() {
let mut result_items: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
for (ln, val) in &self.eval_results {
result_items.push(
iced_widget::text(format!("Ln {}: {}", ln + 1, val))
.font(Font::MONOSPACE)
.size(11.0)
.color(Color::from_rgb(0.651, 0.890, 0.631))
.into(),
);
}
for (ln, err) in &self.eval_errors {
result_items.push(
iced_widget::text(format!("Ln {}: {}", ln + 1, err))
.font(Font::MONOSPACE)
.size(11.0)
.color(Color::from_rgb(0.890, 0.400, 0.400))
.into(),
);
}
let eval_panel = iced_widget::container(
iced_widget::column(result_items).spacing(2.0),
)
.width(Length::Fill)
.padding(Padding { top: 4.0, right: 10.0, bottom: 4.0, left: 10.0 })
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.10, 0.10, 0.12))),
border: Border::default(),
text_color: None,
shadow: Shadow::default(),
snap: false,
});
col_items.push(eval_panel.into());
}
col_items.push(status_bar.into());
iced_widget::column(col_items)
.height(Length::Fill)
.into()
}
}
fn parse_let_binding(line: &str) -> Option<String> {
let rest = line.strip_prefix("let ")?;
let eq_pos = rest.find('=')?;
if rest.as_bytes().get(eq_pos + 1) == Some(&b'=') {
return None;
}
let name_part = rest[..eq_pos].trim();
let name = if let Some(colon) = name_part.find(':') {
name_part[..colon].trim()
} else {
name_part
};
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
Some(name.to_string())
}
fn macos_key_binding(key_press: KeyPress) -> Option<Binding<Message>> {
let KeyPress { key, modifiers, status, .. } = &key_press;
if !matches!(status, Status::Focused { .. }) {
return None;
}
match key.as_ref() {
keyboard::Key::Character("=" | "+") if modifiers.logo() => {
Some(Binding::Custom(Message::ZoomIn))
}
keyboard::Key::Character("-") if modifiers.logo() => {
Some(Binding::Custom(Message::ZoomOut))
}
keyboard::Key::Character("0") if modifiers.logo() => {
Some(Binding::Custom(Message::ZoomReset))
}
keyboard::Key::Named(key::Named::Backspace) if modifiers.alt() => {
Some(Binding::Sequence(vec![
Binding::Select(Motion::WordLeft),
Binding::Backspace,
]))
}
keyboard::Key::Named(key::Named::Delete) if modifiers.alt() => {
Some(Binding::Sequence(vec![
Binding::Select(Motion::WordRight),
Binding::Delete,
]))
}
keyboard::Key::Named(key::Named::ArrowUp) if modifiers.logo() && modifiers.shift() => {
Some(Binding::Select(Motion::DocumentStart))
}
keyboard::Key::Named(key::Named::ArrowDown) if modifiers.logo() && modifiers.shift() => {
Some(Binding::Select(Motion::DocumentEnd))
}
keyboard::Key::Named(key::Named::ArrowUp) if modifiers.logo() => {
Some(Binding::Move(Motion::DocumentStart))
}
keyboard::Key::Named(key::Named::ArrowDown) if modifiers.logo() => {
Some(Binding::Move(Motion::DocumentEnd))
}
_ => Binding::from_key_press(key_press),
}
}
fn lang_from_extension(ext: &str) -> Option<String> {
let lang = match ext {
"rs" => "rust",
"c" | "h" => "c",
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp",
"js" | "mjs" | "cjs" => "javascript",
"jsx" => "jsx",
"ts" | "mts" | "cts" => "typescript",
"tsx" => "tsx",
"py" => "python",
"go" => "go",
"rb" => "ruby",
"sh" | "bash" | "zsh" => "bash",
"java" => "java",
"html" | "htm" => "html",
"css" => "css",
"scss" => "scss",
"less" => "less",
"json" => "json",
"lua" => "lua",
"php" => "php",
"toml" => "toml",
"yaml" | "yml" => "yaml",
"swift" => "swift",
"zig" => "zig",
"sql" => "sql",
"mk" => "make",
"cord" | "cordial" => "rust",
_ => return None,
};
Some(lang.to_string())
}
fn detect_lang_from_content(text: &str) -> Option<String> {
let keywords = ["fn ", "let ", "if ", "else ", "while ", "for ", "/="];
let mut hits = 0;
for line in text.lines().take(50) {
let trimmed = line.trim();
for kw in &keywords {
if trimmed.starts_with(kw) || trimmed.contains(&format!(" {kw}")) {
hits += 1;
}
}
if hits >= 2 {
return Some("rust".into());
}
}
None
}
fn leading_whitespace(line: &str) -> &str {
let end = line.len() - line.trim_start().len();
&line[..end]
}