514 lines
18 KiB
Rust
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]
|
|
}
|