//! Editoraktionen auf gemeinsamen Dokumenten und revisionsgebundene Übersetzung. use crate::{ app::{App, Dialog, DialogKind, Field, WindowKind}, commands::Command, documents::{DocumentId, ViewId}, export::ProjectStamp, }; use anyhow::{anyhow, ensure, Result}; use crossterm::event::{KeyCode as K, KeyEvent, KeyModifiers as M}; use std::{collections::BTreeMap, ops::Range, path::PathBuf}; use tb_frontend::{ lexer::{self, TokenKind}, source::SourceUnit, Diagnostic, }; use tb_vm::{bytecode::CompiledModule, project::ProjectCompiler}; use unicode_width::UnicodeWidthChar; #[derive(Default, Clone)] pub struct Search { pub text: String, pub replacement: String, pub backwards: bool, pub case_sensitive: bool, pub whole_word: bool, pub scope: Option>, pub document: Option, pub wrap: bool, } #[derive(Default)] pub struct Editor { pub clipboard: String, pub chord: Option, pub search: Search, pub history: Vec<(DocumentId, usize)>, pub expansions: BTreeMap, pub checked: BTreeMap, pub compiler: ProjectCompiler, pub diagnostics: Vec, revision: Option<(ProjectStamp, Vec, Vec)>, compiled: Option, } pub fn line_start(code: &str, at: usize) -> usize { code[..at].rfind('\n').map_or(0, |p| p + 1) } pub fn line_end(code: &str, at: usize) -> usize { code[at..].find('\n').map_or(code.len(), |p| at + p) } pub fn column(code: &str, tab: usize) -> usize { code.chars().fold(0, |n, c| { n + if c == '\t' { tab - n % tab } else { c.width().unwrap_or(0) } }) } pub fn offset(code: &str, col: usize, tab: usize) -> usize { let mut width = 0; for (i, c) in code.char_indices() { let w = if c == '\t' { tab - width % tab } else { c.width().unwrap_or(0) }; if width + w > col { return i; } width += w; } code.len() } fn word(c: char) -> bool { c.is_alphanumeric() || c == '_' || "%&!#$@".contains(c) } fn previous(code: &str, at: usize) -> usize { if code[..at].ends_with("\r\n") { return at - 2; } code[..at].char_indices().last().map_or(0, |(i, _)| i) } fn next(code: &str, at: usize) -> usize { if code[at..].starts_with("\r\n") { return at + 2; } at + code[at..].chars().next().map_or(0, char::len_utf8) } fn matches(code: &str, search: &Search, scope: Range) -> Vec> { if search.text.is_empty() { return vec![]; } let mut result = Vec::new(); let mut end_previous = scope.start; for (i, _) in code[scope.clone()].char_indices() { let start = scope.start + i; if start < end_previous { continue; } let end = code[start..scope.end] .char_indices() .nth(search.text.chars().count()) .map_or(scope.end, |(i, _)| start + i); let value = &code[start..end]; let equal = if search.case_sensitive { value == search.text } else { value.to_lowercase() == search.text.to_lowercase() }; if equal && (!search.whole_word || (!code[..start].chars().next_back().is_some_and(word) && !code[end..].chars().next().is_some_and(word))) { result.push(start..end); end_previous = end; } } result } impl App { pub(crate) fn editor_view(&self) -> Result { match self.active_window().map(|w| w.kind) { Some(WindowKind::Code(v)) => Ok(v), _ => Err(anyhow!("Kein Codefenster aktiv")), } } pub(crate) fn editor_insert(&mut self, text: &str) -> Result<()> { let v = self.editor_view()?; ensure!( !self.editor.expansions.contains_key(&v), "Included Lines ist schreibgeschützt; Included File öffnet die Originaldatei" ); let view = self.project.view(v)?; let id = view.document(); let range = view.selection().unwrap_or(view.cursor..view.cursor); let cursor = range.start + text.len(); self.project.replace_text(id, range, text)?; let view = self.project.view_mut(v)?; view.cursor = cursor; view.anchor = None; self.editor_scroll()?; Ok(()) } pub(crate) fn editor_scroll(&mut self) -> Result<()> { let v = self.editor_view()?; let rect = self.rect(self.active_window().unwrap()); let view = self.project.view(v)?; let code = self.project.document(view.document())?.code(); let row = code[..view.cursor].bytes().filter(|b| *b == b'\n').count(); let col = column( &code[line_start(code, view.cursor)..view.cursor], self.options.tab_width, ); let view = self.project.view_mut(v)?; let height = rect.height.saturating_sub(2).max(1) as usize; let width = rect.width.saturating_sub(2).max(1) as usize; if row < view.scroll_line { view.scroll_line = row; } else if row >= view.scroll_line + height { view.scroll_line = row + 1 - height; } if col < view.scroll_column { view.scroll_column = col; } else if col >= view.scroll_column + width { view.scroll_column = col + 1 - width; } Ok(()) } pub(crate) fn editor_key(&mut self, key: KeyEvent) -> Result<()> { let v = self.editor_view()?; if let Some(expansion) = self.editor.expansions.get(&v) { let count = expansion.lines().count(); let step = self .rect(self.active_window().unwrap()) .height .saturating_sub(3) .max(1) as usize; let view = self.project.view_mut(v)?; match key.code { K::Up => view.scroll_line = view.scroll_line.saturating_sub(1), K::Down => view.scroll_line = (view.scroll_line + 1).min(count.saturating_sub(1)), K::PageUp => view.scroll_line = view.scroll_line.saturating_sub(step), K::PageDown => { view.scroll_line = (view.scroll_line + step).min(count.saturating_sub(1)) } K::Home => view.scroll_line = 0, K::End => view.scroll_line = count.saturating_sub(step), _ => {} } return Ok(()); } let view = self.project.view(v)?.clone(); let id = view.document(); let code = self.project.document(id)?.code().to_owned(); let at = view.cursor; let ctrl = key.modifiers.contains(M::CONTROL); let shift = key.modifiers.contains(M::SHIFT); let mut destination; if let Some(chord) = self.editor.chord.take() { if let K::Char(c) = key.code { match (chord, c.to_ascii_lowercase()) { ('q', 's') => destination = line_start(&code, at), ('q', 'd') => destination = line_end(&code, at), ('q', 'r') => destination = 0, ('q', 'c') => destination = code.len(), ('k', c @ '0'..='3') => { self.project.view_mut(v)?.bookmarks[c as usize - '0' as usize] = Some(at); return Ok(()); } ('q', c @ '0'..='3') => { destination = view.bookmarks[c as usize - '0' as usize].unwrap_or(at) } _ => return Ok(()), } } else { return Ok(()); } } else { match key.code { K::Char(c @ ('q' | 'k')) if ctrl => { self.editor.chord = Some(c); self.message = format!("Ctrl+{} … (Esc bricht ab)", c.to_ascii_uppercase()); return Ok(()); } K::Char('a') if ctrl => { let v = self.project.view_mut(v)?; v.anchor = Some(0); v.cursor = code.len(); return self.editor_scroll(); } K::Char('y') if ctrl => { let range = line_start(&code, at)..(line_end(&code, at) + 1).min(code.len()); self.project.replace_text(id, range.clone(), "")?; let v = self.project.view_mut(v)?; v.cursor = range.start; v.anchor = None; return self.editor_scroll(); } K::Insert => { self.project.view_mut(v)?.overwrite = !view.overwrite; self.message = if view.overwrite { "Einfügen" } else { "Überschreiben" } .into(); return Ok(()); } K::Char(c) if !key.modifiers.intersects(M::ALT | M::CONTROL) => { if view.overwrite && view.selection().is_none() && at < line_end(&code, at) && !code[at..].starts_with("\r\n") { self.project .replace_text(id, at..next(&code, at), &c.to_string())?; self.project.view_mut(v)?.cursor = at + c.len_utf8(); return self.editor_scroll(); } return self.editor_insert(&c.to_string()); } K::Enter => { return self.editor_insert(if code.contains("\r\n") { "\r\n" } else { "\n" }) } K::Tab => return self.editor_insert("\t"), K::Backspace | K::Delete => { let range = view.selection().unwrap_or_else(|| { if key.code == K::Backspace { let p = previous(&code, at); if code.get(p..at) == Some("\n") && p > 0 && code.as_bytes()[p - 1] == b'\r' { p - 1..at } else { p..at } } else { at..if code[at..].starts_with("\r\n") { at + 2 } else { next(&code, at) } } }); self.project.replace_text(id, range.clone(), "")?; let view = self.project.view_mut(v)?; view.cursor = range.start; view.anchor = None; return self.editor_scroll(); } K::Left | K::Right => { destination = if key.code == K::Left { previous(&code, at) } else { next(&code, at) }; if ctrl { if key.code == K::Left { while destination > 0 && !code[destination..].chars().next().is_some_and(word) { destination = previous(&code, destination); } while destination > 0 && code[..destination].chars().next_back().is_some_and(word) { destination = previous(&code, destination); } } else { destination = at; while destination < code.len() && code[destination..].chars().next().is_some_and(word) { destination = next(&code, destination); } while destination < code.len() && !code[destination..].chars().next().is_some_and(word) { destination = next(&code, destination); } } } } K::Home => destination = if ctrl { 0 } else { line_start(&code, at) }, K::End => { destination = if ctrl { code.len() } else { line_end(&code, at) }; if code[..destination].ends_with('\r') { destination -= 1; } } K::Up | K::Down | K::PageUp | K::PageDown => { let starts: Vec<_> = std::iter::once(0) .chain(code.match_indices('\n').map(|(i, _)| i + 1)) .collect(); let row = starts.partition_point(|i| *i <= at) - 1; let col = column(&code[starts[row]..at], self.options.tab_width); let step = if matches!(key.code, K::PageUp | K::PageDown) { self.rect(self.active_window().unwrap()) .height .saturating_sub(3) .max(1) as usize } else { 1 }; let row = if matches!(key.code, K::Up | K::PageUp) { row.saturating_sub(step) } else { (row + step).min(starts.len() - 1) }; let start = starts[row]; let end = line_end(&code, start); destination = start + offset( code[start..end].trim_end_matches('\r'), col, self.options.tab_width, ); } K::Esc => { self.project.view_mut(v)?.anchor = None; return Ok(()); } _ => return Ok(()), } } if !shift && !ctrl { if let Some(range) = view.selection() { if key.code == K::Left { destination = range.start; } if key.code == K::Right { destination = range.end; } } } let view = self.project.view_mut(v)?; if shift { view.anchor.get_or_insert(at); } else { view.anchor = None; } view.cursor = destination; self.editor_scroll() } pub(crate) fn editor_command(&mut self, command: Command) -> Result<()> { use Command::*; let v = self.editor_view()?; let view = self.project.view(v)?.clone(); let id = view.document(); let code = self.project.document(id)?.code().to_owned(); match command { Copy | Cut => { if let Some(range) = view.selection() { self.editor.clipboard = code[range].into(); if command == Cut { self.editor_insert("")?; } } } Paste => self.editor_insert(&self.editor.clipboard.clone())?, Clear => self.editor_key(KeyEvent::new(K::Delete, M::NONE))?, Find | Replace => { let s = &self.editor.search; self.open_dialog( if command == Find { "Find" } else { "Change" }, DialogKind::Search { view: v, selection: view.selection(), }, vec![ Field::text("Suchtext", s.text.clone()), Field::text("Ersetzung", s.replacement.clone()), Field::toggle("Rückwärts", s.backwards), Field::toggle("Groß-/Kleinschreibung", s.case_sensitive), Field::toggle("Ganzes Wort", s.whole_word), Field::toggle("Nur Auswahl", false), Field::choice( "Aktion", vec![ "Suchen".into(), "Einzeln ersetzen".into(), "Alle ersetzen".into(), ], if command == Find { 0 } else { 1 }, ), ], ); } SelectedText => { let range = view .selection() .ok_or_else(|| anyhow!("Kein Text ausgewählt"))?; self.editor.search.text = code[range].into(); self.editor.search.document = Some(id); self.editor.search.scope = None; self.editor.search.wrap = false; self.find_next()?; } FindNext => self.find_next()?, NewSub | NewFunction => self.open_dialog( if command == NewSub { "New Sub" } else { "New Function" }, DialogKind::Procedure(command == NewFunction), vec![Field::text("Name", "")], ), Procedures => { let parsed = tb_frontend::parser::parse("EDITOR", &lexer::lex(&code).tokens); let mut positions = vec![0]; let mut names = vec!["Modulrumpf".into()]; for p in parsed.module.procs { names.push(tb_frontend::editing::signature(&p.sig)); positions.push(line_offset(&code, p.pos.line)); } self.open_dialog( "Prozeduren", DialogKind::Procedures(positions), vec![Field::choice("Prozedur", names, 0)], ); } PreviousCode => { if let Some((id, at)) = self.editor.history.pop() { self.show_document(id, false)?; let v = self.editor_view()?; let code = self.project.document(id)?.code(); let mut at = at.min(code.len()); while !code.is_char_boundary(at) { at -= 1; } self.project.view_mut(v)?.cursor = at; self.editor_scroll()?; } } IncludedFile => { let line = &code[line_start(&code, view.cursor)..line_end(&code, view.cursor)]; let path = lexer::lex(line) .tokens .into_iter() .find_map(|t| { if let TokenKind::MetaInclude(p) = t.kind { Some(p) } else { None } }) .ok_or_else(|| anyhow!("Keine Include-Anweisung auf dieser Zeile"))?; let doc = self.project.document(id)?; let path = self .project .loader()? .resolve(doc.source_path().parent().unwrap(), &path) .map_err(|e| anyhow!(e))?; self.editor.history.push((id, view.cursor)); let id = self.project.open_document(&path)?; self.show_document(id, false)?; } IncludedLines => { if self.editor.expansions.remove(&v).is_none() { let sources = self.project.sources()?; let path = self .project .document(id)? .source_path() .display() .to_string(); let unit = sources .units .iter() .find(|u| u.segments.iter().any(|s| s.file == path)) .ok_or_else(|| anyhow!("Dokument gehört zu keiner Übersetzungseinheit"))?; let mut text = String::from("Included Lines · SCHREIBGESCHÜTZT\n"); for s in &unit.segments { text.push_str(&format!("' {}:{}\n{}\n", s.file, s.first_line, s.text)); } self.editor.expansions.insert(v, text); self.project.view_mut(v)?.scroll_line = 0; self.project.view_mut(v)?.scroll_column = 0; } else { self.editor_scroll()?; } } Diagnostics => self.diagnostic_dialog(), _ => unreachable!(), } Ok(()) } pub(crate) fn editor_submit(&mut self, d: &mut Dialog) -> Result { match d.kind.clone() { DialogKind::Search { view, selection } => { let id = self.project.view(view)?.document(); ensure!(!d.fields[0].string().is_empty(), "Suchtext fehlt"); ensure!( !d.fields[5].flag() || selection.is_some(), "Keine Auswahl vorhanden" ); self.editor.search = Search { text: d.fields[0].string(), replacement: d.fields[1].string(), backwards: d.fields[2].flag(), case_sensitive: d.fields[3].flag(), whole_word: d.fields[4].flag(), scope: if d.fields[5].flag() { selection } else { None }, document: Some(id), wrap: false, }; match d.fields[6].index() { 0 => self.find_next()?, 1 => self.replace_matches(false)?, _ => self.replace_matches(true)?, } } DialogKind::Procedure(function) => { let v = self.editor_view()?; let view = self.project.view(v)?.clone(); let id = view.document(); let code = self.project.document(id)?.code(); let input = self.project.sources()?; let name = d.fields[0].string(); for unit in &input.units { let (module, _) = unit.parse(0, &mut Vec::new()); ensure!( !module .procs .iter() .any(|p| p.sig.name.eq_ignore_ascii_case(&name)), "Prozedurname bereits im Projekt vorhanden" ); } let path = self .project .document(id)? .source_path() .display() .to_string(); let unit = input .units .iter() .find(|u| u.segments.iter().any(|s| s.file == path)) .ok_or_else(|| anyhow!("Keine Übersetzungseinheit"))?; let first = match self.project.document(id)?.content() { tb_vm::project_io::Content::Form(f) => { tb_ui::frm::read_text(&path, &tb_ui::frm::write_text(f))?.code_line() } _ => 1, }; let line = first + code[..view.cursor].bytes().filter(|b| *b == b'\n').count() as u32; let col = view.cursor - line_start(code, view.cursor); let mut expanded = String::new(); let mut at = None; for segment in &unit.segments { for (i, text) in segment.text.split_inclusive('\n').enumerate() { if segment.file == path && segment.first_line + i as u32 == line { at = Some( expanded.len() + col.min(text.trim_end_matches(['\n', '\r']).len()), ); } expanded.push_str(text); if !text.ends_with('\n') { expanded.push('\n'); } } } let addition = tb_frontend::editing::new_procedure( &expanded, at.unwrap_or(expanded.len()), &name, function, ) .map_err(|e| anyhow!(e))?; let end = code.len(); self.project.replace_text(id, end..end, &addition)?; self.project.view_mut(v)?.cursor = end + 1; self.editor_scroll()?; } DialogKind::Procedures(positions) => { let v = self.editor_view()?; let view = self.project.view(v)?; self.editor.history.push((view.document(), view.cursor)); self.project.view_mut(v)?.cursor = positions[d.fields[0].index()]; self.editor_scroll()?; } DialogKind::Diagnostics => { ensure!( self.revision_current(), "Quellen geändert; Diagnosen erneut öffnen" ); let diagnostic = self .editor .diagnostics .get(d.fields[0].index()) .cloned() .ok_or_else(|| anyhow!("Keine Diagnose"))?; self.goto_diagnostic(&diagnostic)?; } _ => unreachable!(), } Ok(false) } fn find_next(&mut self) -> Result<()> { let v = self.editor_view()?; let view = self.project.view(v)?.clone(); let id = view.document(); let code = self.project.document(id)?.code(); let s = &mut self.editor.search; ensure!(!s.text.is_empty(), "Noch keine Suche; Find verwenden"); if s.document != Some(id) { s.document = Some(id); s.scope = None; s.wrap = false; } let scope = s .scope .clone() .filter(|r| code.get(r.clone()).is_some()) .unwrap_or(0..code.len()); let ranges = matches(code, s, scope); let found = if s.backwards { let at = view.selection().map_or(view.cursor, |r| r.start); ranges.iter().rev().find(|r| s.wrap || r.end <= at) } else { let at = view.selection().map_or(view.cursor, |r| r.end); ranges.iter().find(|r| s.wrap || r.start >= at) }; if let Some(r) = found { let view = self.project.view_mut(v)?; view.anchor = Some(r.start); view.cursor = r.end; s.wrap = false; self.message = "Treffer".into(); self.editor_scroll()?; } else { s.wrap = true; self.message = "Suchbereich beendet · F3 bietet Rücksprung zum anderen Ende".into(); } Ok(()) } fn replace_matches(&mut self, all: bool) -> Result<()> { let v = self.editor_view()?; let view = self.project.view(v)?.clone(); let id = view.document(); ensure!( !self.editor.expansions.contains_key(&v), "Included Lines ist schreibgeschützt" ); let code = self.project.document(id)?.code().to_owned(); let s = &self.editor.search; let scope = s.scope.clone().unwrap_or(0..code.len()); ensure!( code.get(scope.clone()).is_some(), "Suchbereich nicht mehr gültig" ); let ranges = matches(&code, s, scope.clone()); let ranges = if all { ranges } else { let found = if s.backwards { ranges.into_iter().rev().find(|r| r.start <= view.cursor) } else { ranges .into_iter() .find(|r| r.end > view.cursor || view.selection().as_ref() == Some(r)) }; found.into_iter().collect() }; if ranges.is_empty() { self.message = "Keine Treffer".into(); return Ok(()); } let mut replacement = code[scope.clone()].to_owned(); for r in ranges.iter().rev() { replacement.replace_range(r.start - scope.start..r.end - scope.start, &s.replacement); } let first = &ranges[0]; let cursor = first.start + s.replacement.len(); self.project.replace_text(id, scope.clone(), &replacement)?; if self.editor.search.scope.is_some() { self.editor.search.scope = Some(scope.start..scope.start + replacement.len()); } let view = self.project.view_mut(v)?; view.cursor = cursor; view.anchor = None; self.message = format!("{} Treffer ersetzt", ranges.len()); self.editor_scroll() } pub(crate) fn diagnostic_dialog(&mut self) { if !self.revision_current() { if let Err(e) = self.compile_current() { self.message = e.to_string(); } } if self.editor.diagnostics.is_empty() { self.message = "Keine Diagnosen".into(); return; } self.open_dialog( "Diagnosen", DialogKind::Diagnostics, vec![Field::choice( "Datei:Zeile:Spalte", self.editor .diagnostics .iter() .map(ToString::to_string) .collect(), 0, )], ); } pub fn goto_diagnostic(&mut self, d: &Diagnostic) -> Result<()> { let path = PathBuf::from( d.file .as_ref() .ok_or_else(|| anyhow!("Diagnose ohne Quelldatei"))?, ); let id = self.project.open_document(&path)?; self.show_document(id, false)?; let v = self.editor_view()?; let doc = self.project.document(id)?; let first = if let tb_vm::project_io::Content::Form(f) = doc.content() { tb_ui::frm::read_text(&path.display().to_string(), &tb_ui::frm::write_text(f))? .code_line() .saturating_sub(1) } else { 0 }; let code = doc.code(); let start = line_offset(code, d.pos.line.saturating_sub(first)); let at = start + code[start..line_end(code, start)] .char_indices() .nth(d.pos.column.saturating_sub(1) as usize) .map_or(line_end(code, start) - start, |(i, _)| i); self.editor.expansions.remove(&v); self.project.view_mut(v)?.cursor = at; self.editor_scroll() } /// Immer aktuelle Quellen einschließlich externer Includes prüfen; alte Erfolge werden nie herausgegeben. pub fn compile_current(&mut self) -> Result<&CompiledModule> { self.editor.compiled = None; self.editor.revision = None; self.editor.diagnostics.clear(); let sources = self.project.sources()?; let mut catalog = tb_frontend::forms::FormCatalog::default(); for f in &sources.forms { catalog.append(&f.catalog()); } self.editor.revision = Some(( ProjectStamp::capture(&self.project), sources.units.clone(), sources.forms.iter().map(tb_ui::frm::write_text).collect(), )); match self .editor .compiler .compile("IDE", &sources.units, &catalog, &sources.forms) { Ok(code) => { self.editor.compiled = Some(code); Ok(self.editor.compiled.as_ref().unwrap()) } Err(errors) => { let text = errors .iter() .map(ToString::to_string) .collect::>() .join("\n"); self.editor.diagnostics = errors; Err(anyhow!(text)) } } } fn revision_current(&self) -> bool { let Some((stamp, units, forms)) = &self.editor.revision else { return false; }; if stamp != &ProjectStamp::capture(&self.project) { return false; } let Ok(current) = self.project.sources() else { return false; }; units == ¤t.units && forms == ¤t .forms .iter() .map(tb_ui::frm::write_text) .collect::>() } pub fn current_compilation(&self) -> Option<&CompiledModule> { self.revision_current() .then_some(self.editor.compiled.as_ref()) .flatten() } pub fn current_diagnostics(&self) -> Option<&[Diagnostic]> { self.revision_current() .then_some(self.editor.diagnostics.as_slice()) } pub(crate) fn editor_position(&self) -> Option<(ViewId, DocumentId, usize)> { let v = self.editor_view().ok()?; let view = self.project.view(v).ok()?; let code = self.project.document(view.document()).ok()?.code(); Some((v, view.document(), line_start(code, view.cursor))) } pub(crate) fn line_leave(&mut self, old: Option<(ViewId, DocumentId, usize)>) { let Some((v, id, start)) = old else { return; }; if !self.options.syntax_checking || self.editor.expansions.contains_key(&v) || old == self.editor_position() { return; } let Ok(doc) = self.project.document(id) else { return; }; if doc.revision() == 0 || self.editor.checked.get(&id) == Some(&doc.revision()) { return; } let code = doc.code().to_owned(); let revision = doc.revision(); let lexed = lexer::lex(&code); let parsed = tb_frontend::parser::parse("EDITOR", &lexed.tokens); if lexed.diagnostics.is_empty() && parsed.diagnostics.is_empty() && code.is_char_boundary(start) && start <= code.len() { let end = line_end(&code, start); let edits = tb_frontend::editing::normalization_edits(&code[start..end]) .into_iter() .map(|(r, t)| (start + r.start..start + r.end, t)) .collect::>(); if let Err(e) = self.project.replace_ranges(id, &edits) { self.message = e.to_string(); return; } } self.editor.checked.insert( id, self.project.document(id).map_or(revision, |d| d.revision()), ); let result = self.compile_current().map(|_| ()); if parsed.incomplete && lexed.diagnostics.is_empty() { self.message = "Quelltext noch unvollständig · Eingabe fortsetzen".into(); } else if let Err(e) = result { self.message = e.to_string(); if self.dialog.is_none() { self.diagnostic_dialog(); } } else { self.message = format!( "Geprüft · {} Module übersetzt, {} wiederverwendet", self.editor.compiler.stats.compiled, self.editor.compiler.stats.reused ); } } } pub fn line_offset(code: &str, line: u32) -> usize { if line <= 1 { 0 } else { code.match_indices('\n') .nth(line as usize - 2) .map_or(code.len(), |(i, _)| i + 1) } }