Phase 5: Editor und inkrementellen Compiler implementieren und archivieren

This commit is contained in:
2026-09-06 17:58:26 +02:00
parent df85a4b7b2
commit 9c85349a4d
36 changed files with 2993 additions and 247 deletions

View File

@@ -185,6 +185,13 @@ pub enum AfterSave {
}
#[derive(Debug, Clone)]
pub enum DialogKind {
Search {
view: ViewId,
selection: Option<std::ops::Range<usize>>,
},
Procedure(bool),
Procedures(Vec<usize>),
Diagnostics,
Browse {
return_to: Box<Dialog>,
field: usize,
@@ -249,6 +256,7 @@ pub enum Hit {
}
pub struct App {
pub editor: crate::editor::Editor,
pub project: Project,
pub options: Options,
pub mode: Mode,
@@ -285,6 +293,7 @@ impl App {
let (options, errors, config_disk) = Options::load(&config_path);
project.include_paths = options.include_paths.clone();
let mut app = Self {
editor: Default::default(),
project,
options: options.clone(),
saved_options: options,
@@ -393,8 +402,13 @@ impl App {
.into_owned()
});
format!(
"[{}] {}{}",
"[{}] {}{}{}",
w.id,
if self.editor.expansions.contains_key(&v) {
"Included Lines [read-only] · "
} else {
""
},
name,
if doc.is_dirty() { " *" } else { "" }
)
@@ -438,12 +452,41 @@ impl App {
return Some(format!("Fachfunktion folgt in Phase-5-Change {phase:02}"));
}
use Command::*;
if matches!(command, LoadText | SaveText)
&& !matches!(
self.active_window().map(|w| w.kind),
Some(WindowKind::Code(_))
)
if matches!(
command,
Undo | Cut | Paste | Clear | LoadText | NewSub | NewFunction | Replace
) && self
.editor_view()
.is_ok_and(|v| self.editor.expansions.contains_key(&v))
{
return Some(
"Included Lines ist schreibgeschützt; Included File öffnet die Originaldatei"
.into(),
);
}
if matches!(
command,
LoadText
| SaveText
| Cut
| Copy
| Paste
| Clear
| NewSub
| NewFunction
| IncludedFile
| IncludedLines
| Find
| SelectedText
| FindNext
| Replace
| Procedures
| PreviousCode
| Diagnostics
) && !matches!(
self.active_window().map(|w| w.kind),
Some(WindowKind::Code(_))
) {
return Some("Kein Codefenster aktiv".into());
}
if matches!(
@@ -489,6 +532,9 @@ impl App {
use Command::*;
let id = self.active_document();
match command {
Cut | Copy | Paste | Clear | NewSub | NewFunction | IncludedFile | IncludedLines
| Find | SelectedText | FindNext | Replace | Procedures | PreviousCode
| Diagnostics => self.editor_command(command)?,
NewProject => self.change_project(AfterSave::NewProject)?,
OpenProject => self.open_dialog(
"Open Project",
@@ -531,7 +577,10 @@ impl App {
{
self.save_dialog(false, vec![id], AfterSave::Stay)?;
} else {
self.message = "Datei gespeichert".into();
self.message = format!(
"Datei gespeichert · {}",
self.project.save_notices.join("; ")
);
}
}
SaveAs => self.save_dialog(false, vec![id.unwrap()], AfterSave::Stay)?,
@@ -795,7 +844,12 @@ impl App {
.display()
.to_string()
}
fn open_dialog(&mut self, title: impl Into<String>, kind: DialogKind, fields: Vec<Field>) {
pub(crate) fn open_dialog(
&mut self,
title: impl Into<String>,
kind: DialogKind,
fields: Vec<Field>,
) {
self.dialog = Some(Dialog::new(title, kind, fields));
}
fn cascade(&self) -> Rect {
@@ -810,7 +864,7 @@ impl App {
self.add_window(kind, self.cascade());
}
}
fn show_document(&mut self, id: DocumentId, form: bool) -> Result<()> {
pub(crate) fn show_document(&mut self, id: DocumentId, form: bool) -> Result<()> {
if form {
ensure!(
matches!(self.project.document(id)?.content(), Content::Form(_)),
@@ -890,6 +944,7 @@ impl App {
}
}
self.base = self.project.directory().to_path_buf();
self.editor = Default::default();
self.windows.clear();
self.active = 0;
self.selected_member = 0;
@@ -961,6 +1016,10 @@ impl App {
}
fn submit(&mut self, d: &mut Dialog) -> Result<bool> {
match d.kind.clone() {
DialogKind::Search { .. }
| DialogKind::Procedure(_)
| DialogKind::Procedures(_)
| DialogKind::Diagnostics => return self.editor_submit(d),
DialogKind::Browse {
mut return_to,
field,
@@ -1075,19 +1134,18 @@ impl App {
} else {
self.project.save_file(ids[0], plan.files.get(&ids[0]))?;
}
self.message = "Gespeichert".into();
self.message = format!("Gespeichert · {}", self.project.save_notices.join("; "));
self.finish_change(after)?;
}
DialogKind::LoadText => {
let (id, at) = self.code_cursor()?;
self.project
.load_text(id, at, &self.base.join(d.fields[0].string()))?;
let text = std::fs::read_to_string(self.base.join(d.fields[0].string()))?;
self.editor_insert(&text)?;
}
DialogKind::SaveText => {
let (id, _) = self.code_cursor()?;
self.project.save_text(
id,
None,
self.project.view(self.editor_view()?)?.selection(),
&Destination {
path: d.fields[0].string().into(),
overwrite: d.fields[1].flag(),
@@ -1189,6 +1247,14 @@ impl App {
Ok((view.document(), view.cursor))
}
pub fn handle(&mut self, event: Event) {
let old = self.editor_position();
if !matches!(event, Event::Key(_) | Event::Resize(_, _)) {
self.editor.chord = None;
}
self.handle_event(event);
self.line_leave(old);
}
fn handle_event(&mut self, event: Event) {
if let Event::Resize(w, h) = event {
self.size = (w, h);
return;
@@ -1253,6 +1319,12 @@ impl App {
}
} else if self.program_focus() {
self.basic_events.push(event);
} else if let Event::Paste(text) = event {
if self.dialog.is_none() && self.menu.is_none() && self.mode == Mode::Environment {
if let Err(e) = self.editor_insert(&text) {
self.message = e.to_string();
}
}
}
}
fn click(&mut self, hit: Hit) {
@@ -1334,6 +1406,16 @@ impl App {
)
}
fn key(&mut self, key: KeyEvent) {
if self.editor.chord.is_some()
&& self.dialog.is_none()
&& self.menu.is_none()
&& !self.program_focus()
{
if let Err(e) = self.editor_key(key) {
self.message = e.to_string();
}
return;
}
if key.code == K::F(2) && self.dialog.is_some() {
self.browse();
return;
@@ -1606,48 +1688,10 @@ impl App {
))
}
fn code_key(&mut self, key: KeyEvent) -> Result<()> {
let Some(Window {
kind: WindowKind::Code(view),
..
}) = self.active_window()
else {
if self.editor_view().is_err() {
return Ok(());
};
let view = *view;
let (id, at) = self.code_cursor()?;
let code = self.project.document(id)?.code().to_owned();
let mut next = at;
match key.code {
K::Char(c) if !key.modifiers.intersects(M::CONTROL | M::ALT) => {
self.project.replace_text(id, at..at, &c.to_string())?;
next += c.len_utf8();
}
K::Enter => {
self.project.replace_text(id, at..at, "\n")?;
next += 1;
}
K::Tab => {
self.project.replace_text(id, at..at, "\t")?;
next += 1;
}
K::Backspace if at > 0 => {
next = code[..at].char_indices().last().unwrap().0;
self.project.replace_text(id, next..at, "")?;
}
K::Left => {
next = code[..at]
.char_indices()
.last()
.map(|(i, _)| i)
.unwrap_or(0)
}
K::Right => next += code[at..].chars().next().map(char::len_utf8).unwrap_or(0),
K::Home => next = code[..at].rfind('\n').map(|n| n + 1).unwrap_or(0),
K::End => next = code[at..].find('\n').map(|n| at + n).unwrap_or(code.len()),
_ => {}
}
self.project.view_mut(view)?.cursor = next;
Ok(())
self.editor_key(key)
}
pub fn menu_entries(&self, index: usize) -> Vec<(String, Option<Command>)> {
if self.control_menu {
@@ -1732,6 +1776,8 @@ pub fn shortcut(key: KeyEvent) -> Option<Command> {
K::F(5) if ctrl => Restore,
K::F(1) if shift => UsingHelp,
K::F(1) => Topic,
K::F(2) if shift => Procedures,
K::F(2) if ctrl => PreviousCode,
K::F(2) => Code,
K::F(3) => FindNext,
K::F(4) => OutputScreen,
@@ -1746,6 +1792,10 @@ pub fn shortcut(key: KeyEvent) -> Option<Command> {
K::F(10) => ProcedureStep,
K::F(12) if shift => Form,
K::F(12) => Events,
K::Insert if ctrl => Copy,
K::Insert if shift => Paste,
K::Delete if shift => Cut,
K::Backspace if key.modifiers.contains(M::ALT) => Undo,
K::Char('c') if ctrl => Copy,
K::Char('x') if ctrl => Cut,
K::Char('v') if ctrl => Paste,

View File

@@ -20,6 +20,9 @@ pub enum Command {
Copy,
Paste,
Clear,
Procedures,
PreviousCode,
Diagnostics,
NewSub,
NewFunction,
Events,
@@ -97,8 +100,6 @@ impl Command {
pub fn feature_phase(self) -> Option<u8> {
use Command::*;
match self {
Cut | Copy | Paste | Clear | NewSub | NewFunction | IncludedFile | IncludedLines
| Find | SelectedText | FindNext | Replace => Some(3),
Print | Shell | Start | Restart | Continue | CommandLine | OutputScreen => Some(4),
Events | Grid | Palette | MenuDesign | Toolbox | Tool(_) => Some(5),
NextStatement | AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches
@@ -194,6 +195,8 @@ pub fn menus(designer: bool) -> Vec<Menu> {
'v',
vec![
item("&Code…", Code),
item("&Procedures…", Procedures),
item("Diagno&stics…", Diagnostics),
item("&Form…", Form),
sep(),
item("&Next Statement", NextStatement),

View File

@@ -31,10 +31,18 @@ pub struct ViewId(u64);
pub struct View {
document: DocumentId,
pub cursor: usize,
pub anchor: Option<usize>,
pub bookmarks: [Option<usize>; 4],
pub overwrite: bool,
pub scroll_line: usize,
pub scroll_column: usize,
}
impl View {
pub fn selection(&self) -> Option<Range<usize>> {
self.anchor
.filter(|a| *a != self.cursor)
.map(|a| a.min(self.cursor)..a.max(self.cursor))
}
pub fn document(&self) -> DocumentId {
self.document
}
@@ -121,6 +129,7 @@ pub struct Project {
documents: BTreeMap<DocumentId, Document>,
views: BTreeMap<ViewId, View>,
pub include_paths: Vec<PathBuf>,
pub save_notices: Vec<String>,
}
impl Project {
pub fn directory(&self) -> &Path {
@@ -136,6 +145,7 @@ impl Project {
documents: BTreeMap::new(),
views: BTreeMap::new(),
include_paths: Vec::new(),
save_notices: Vec::new(),
})
}
pub fn open(path: &Path, include_paths: Vec<PathBuf>) -> Result<Self> {
@@ -385,6 +395,9 @@ impl Project {
View {
document,
cursor: 0,
anchor: None,
bookmarks: [None; 4],
overwrite: false,
scroll_line: 0,
scroll_column: 0,
},
@@ -415,6 +428,32 @@ impl Project {
if old.content == content {
return Ok(());
}
let before = old.code();
let after = content.code();
let prefix = before
.chars()
.zip(after.chars())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>();
let suffix = before[prefix..]
.chars()
.rev()
.zip(after[prefix..].chars().rev())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>();
let removed = prefix..before.len() - suffix;
let inserted = after.len() - prefix - suffix;
let remap = |at: usize| {
if at >= removed.end {
at - removed.len() + inserted
} else if at > removed.start {
removed.start + (at - removed.start).min(inserted)
} else {
at
}
};
let undo = Edit {
content: old.content.clone(),
views: self
@@ -430,39 +469,64 @@ impl Project {
doc.revision += 1;
let code = doc.code();
for view in self.views.values_mut().filter(|v| v.document == id) {
view.cursor = boundary(code, view.cursor);
view.cursor = boundary(code, remap(view.cursor));
view.anchor = view.anchor.map(|at| boundary(code, remap(at)));
view.bookmarks = view
.bookmarks
.map(|at| at.map(|at| boundary(code, remap(at))));
}
Ok(())
}
pub fn replace_text(&mut self, id: DocumentId, range: Range<usize>, text: &str) -> Result<()> {
self.replace_ranges(id, &[(range, text.to_owned())])
}
/// Geordnete, disjunkte Ersetzungen: eine Undo-Einheit und positionsgetreue Ansichten.
pub fn replace_ranges(
&mut self,
id: DocumentId,
edits: &[(Range<usize>, String)],
) -> Result<()> {
let mut content = self.document(id)?.content.clone();
ensure!(
range.start <= range.end && content.code().get(range.clone()).is_some(),
"Ungültiger Textbereich"
);
content.code_mut().replace_range(range.clone(), text);
let cursors: Vec<_> = self
let mut end = 0;
for (range, _) in edits {
ensure!(
range.start >= end
&& range.start <= range.end
&& content.code().get(range.clone()).is_some(),
"Ungültiger Textbereich"
);
end = range.end;
}
let views: Vec<_> = self
.views
.iter()
.filter(|(_, v)| v.document == id)
.map(|(id, v)| (*id, v.cursor))
.map(|(id, v)| (*id, v.clone()))
.collect();
for (range, text) in edits.iter().rev() {
content.code_mut().replace_range(range.clone(), text);
}
self.commit_edit(id, content)?;
for (view_id, cursor) in cursors {
for (view_id, old) in views {
let map = |mut at: usize| {
for (range, text) in edits.iter().rev() {
if at > range.start {
at = if at >= range.end {
at - range.len() + text.len()
} else {
range.start + (at - range.start).min(text.len())
};
}
}
boundary(self.documents[&id].code(), at)
};
let cursor = map(old.cursor);
let anchor = old.anchor.map(map);
let bookmarks = old.bookmarks.map(|p| p.map(map));
let view = self.views.get_mut(&view_id).unwrap();
view.cursor = cursor;
if view.cursor > range.start {
view.cursor = if view.cursor >= range.end {
view.cursor - range.len() + text.len()
} else {
range.start + text.len()
};
}
}
// Neue Positionen werden auch nach Ersetzungen mit Unicode begrenzt.
let code = self.documents[&id].code();
for view in self.views.values_mut().filter(|v| v.document == id) {
view.cursor = boundary(code, view.cursor);
view.anchor = anchor;
view.bookmarks = bookmarks;
}
Ok(())
}
@@ -497,6 +561,8 @@ impl Project {
let code = doc.code();
for view in self.views.values_mut().filter(|v| v.document == id) {
view.cursor = boundary(code, view.cursor);
view.anchor = view.anchor.map(|p| boundary(code, p));
view.bookmarks = view.bookmarks.map(|p| p.map(|p| boundary(code, p)));
}
Ok(true)
}
@@ -612,10 +678,64 @@ impl Project {
pub fn save_file(&mut self, id: DocumentId, target: Option<&Destination>) -> Result<()> {
let (path, overwrite) = self.file_target(id, target)?;
let doc = self.document(id)?;
let content = loaded(
let mut content = loaded(
self.loader()?
.relocate(&doc.content, &doc.source_path, &path),
)?;
let prospective = |code: &str| -> std::result::Result<ProjectSources, String> {
let mut loader = self.loader().map_err(|e| e.to_string())?;
let mut candidate = content.clone();
*candidate.code_mut() = code.to_owned();
loader.insert(&path, candidate)?;
let mut manifest = self.manifest.clone();
manifest.rename(&doc.source_path, &path);
// Includes werden im Kontext ihrer Verbraucher geprüft.
loader.load_manifest(manifest)
};
let (code, notice) = tb_frontend::editing::maintain_declarations_with(
content.code(),
|code| {
let input = prospective(code)?;
let unit = input
.units
.iter()
.find(|u| {
u.segments
.iter()
.any(|s| s.file == path.display().to_string())
})
.ok_or_else(|| "Keine Übersetzungseinheit für dieses Dokument".to_owned())?;
let mut sources = Vec::new();
let (mut module, mut diagnostics) = unit.parse(0, &mut sources);
tb_frontend::source::locate_diagnostics(&mut diagnostics, &sources);
if let Some(d) = diagnostics.first() {
return Err(d.to_string());
}
// Nur Prozeduren dieses physischen Dokuments pflegen, nicht die eines Includes.
module.procs.retain(|p| {
sources
.get(p.pos.source as usize)
.is_some_and(|s| s.path == path.display().to_string())
});
Ok(module)
},
|code| {
let input = prospective(code)?;
let mut catalog = tb_frontend::forms::FormCatalog::default();
for form in &input.forms {
catalog.append(&form.catalog());
}
tb_vm::compile_project("SAVE", &input.units, &catalog, &input.forms)
.map(|_| ())
.map_err(|d| {
d.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ")
})
},
);
*content.code_mut() = code;
let text = content.text();
let expected = if doc.path.as_deref() == Some(&path) {
doc.disk.as_deref()
@@ -623,6 +743,10 @@ impl Project {
None
};
atomic_write(&path, text.as_bytes(), expected, overwrite)?;
self.save_notices = notice
.into_iter()
.map(|n| format!("{}: {n}", path.display()))
.collect();
self.commit_edit(id, content)?;
let doc = self.documents.get_mut(&id).unwrap();
self.manifest.rename(&doc.source_path, &path);
@@ -686,8 +810,12 @@ impl Project {
expected,
plan.project.as_ref().is_some_and(|d| d.overwrite),
)?;
let mut notices = Vec::new();
for id in ids {
self.save_file(id, plan.files.get(&id))?;
let result = self.save_file(id, plan.files.get(&id));
notices.extend(self.save_notices.clone());
self.save_notices = notices.clone();
result?;
}
// Zweite Konfliktprüfung direkt vor Ersetzen; ein Teilfehler lässt die alte Projektidentität aktiv.
let expected = if self.path.as_deref() == Some(&target) {

918
crates/tb-ide/src/editor.rs Normal file
View File

@@ -0,0 +1,918 @@
//! 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<Range<usize>>,
pub document: Option<DocumentId>,
pub wrap: bool,
}
#[derive(Default)]
pub struct Editor {
pub clipboard: String,
pub chord: Option<char>,
pub search: Search,
pub history: Vec<(DocumentId, usize)>,
pub expansions: BTreeMap<ViewId, String>,
pub checked: BTreeMap<DocumentId, u64>,
pub compiler: ProjectCompiler,
pub diagnostics: Vec<Diagnostic>,
revision: Option<(ProjectStamp, Vec<SourceUnit>, Vec<String>)>,
compiled: Option<CompiledModule>,
}
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<usize>) -> Vec<Range<usize>> {
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<ViewId> {
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<bool> {
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::<Vec<_>>()
.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 == &current.units
&& forms
== &current
.forms
.iter()
.map(tb_ui::frm::write_text)
.collect::<Vec<_>>()
}
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::<Vec<_>>();
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)
}
}

View File

@@ -6,3 +6,5 @@ pub mod export;
pub mod options;
pub mod render;
pub mod terminal;
pub mod editor;

View File

@@ -153,17 +153,74 @@ impl App {
content_style,
);
} else {
let lines: Vec<_> = doc
.code()
.split('\n')
.map(|line| expanded(line, self.options.tab_width))
.collect();
let expansion = self.editor.expansions.get(&view);
let code = expansion.map(String::as_str).unwrap_or(doc.code());
let lines: Vec<_> = code.split('\n').collect();
let count = lines.len();
let longest = lines.iter().map(|s| s.width()).max().unwrap_or(0);
let longest = lines
.iter()
.map(|s| expanded(s, self.options.tab_width).width())
.max()
.unwrap_or(0);
let selection = if expansion.is_none() {
v.selection()
} else {
None
};
let mut start = 0;
let text = lines
.into_iter()
.skip(v.scroll_line)
.map(|s| Line::raw(s.chars().skip(v.scroll_column).collect::<String>()))
.iter()
.enumerate()
.filter_map(|(row, line)| {
let base = start;
start += line.len() + 1;
if row < v.scroll_line {
return None;
}
let mut col = 0;
let mut spans = Vec::new();
for (i, c) in line.char_indices() {
if c == '\r' {
continue;
}
let width = if c == '\t' {
self.options.tab_width - col % self.options.tab_width
} else {
c.width().unwrap_or(0)
};
let end = col + width;
if end > v.scroll_column
&& col < v.scroll_column + inner.width as usize
{
let selected = selection
.as_ref()
.is_some_and(|r| r.contains(&(base + i)));
let text = if c == '\t'
|| col < v.scroll_column
|| end > v.scroll_column + inner.width as usize
{
" ".repeat(
end.min(v.scroll_column + inner.width as usize)
- col.max(v.scroll_column),
)
} else {
c.to_string()
};
spans.push(Span::styled(
text,
if selected {
content_style.add_modifier(Modifier::REVERSED)
} else {
content_style
},
));
} else if width == 0 && !spans.is_empty() {
spans.push(Span::raw(c.to_string()));
}
col = end;
}
Some(Line::from(spans))
})
.collect::<Vec<_>>();
f.render_widget(Paragraph::new(text).style(content_style), inner);
if count > inner.height as usize {
@@ -186,7 +243,11 @@ impl App {
.viewport_content_length(inner.width as usize),
);
}
if active && self.dialog.is_none() && self.menu.is_none() {
if active
&& expansion.is_none()
&& self.dialog.is_none()
&& self.menu.is_none()
{
let before = &doc.code()[..v.cursor.min(doc.code().len())];
let row = before.bytes().filter(|c| *c == b'\n').count();
let col = expanded(

View File

@@ -1,6 +1,6 @@
use crossterm::{
cursor::{Hide, Show},
event::{DisableMouseCapture, EnableMouseCapture},
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
execute,
style::ResetColor,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
@@ -25,7 +25,13 @@ impl<W: Write> TerminalGuard<W> {
fn with_raw(writer: W, raw: fn(bool) -> io::Result<()>) -> io::Result<Self> {
let mut guard = Self { writer, raw };
(guard.raw)(true)?;
execute!(guard.writer, EnterAlternateScreen, EnableMouseCapture, Hide)?;
execute!(
guard.writer,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste,
Hide
)?;
Ok(guard)
}
}
@@ -37,6 +43,7 @@ impl<W: Write> Drop for TerminalGuard<W> {
ResetColor,
Show,
DisableMouseCapture,
DisableBracketedPaste,
LeaveAlternateScreen
);
}

View File

@@ -139,7 +139,8 @@ fn start_snapshot_and_reference_menus_have_all_commands() {
for item in m.items.iter().filter(|i| i.command.is_some()) {
let name = item.text();
assert!(
reference.contains(name.trim_end_matches('…')),
reference.contains(name.trim_end_matches('…'))
|| item.command == Some(Command::Diagnostics),
"Nicht in Referenz: {name}"
);
assert!(item.mnemonic().is_some());
@@ -224,7 +225,8 @@ fn dispatcher_keeps_function_keys_and_copy_out_of_basic_input() {
let mut app = t.app();
key(&mut app, K::Char('c'), M::CONTROL);
assert_eq!(app.last_command, Some(Command::Copy));
assert!(app.message.contains("03"));
assert!(app.editor.clipboard.is_empty());
assert!(app.basic_events.is_empty());
plain(&mut app, K::F(11));
assert!(app.menu.is_some());
plain(&mut app, K::Esc);
@@ -262,6 +264,7 @@ fn dispatcher_keeps_function_keys_and_copy_out_of_basic_input() {
fn windows_restore_geometry_and_resize_without_document_loss() {
let t = Temp::new();
let mut app = t.app();
app.options.syntax_checking = false; // Dieser Test prüft Fenster mit absichtlich ungültigem Entwurf.
type_text(&mut app, "abc");
let doc = app.active_document().unwrap();
let first = app.active;

View File

@@ -450,9 +450,10 @@ fn aliases_share_documents_and_form_code_offsets_match_saved_text() {
let id = p.new_form("F").unwrap();
p.replace_text(id, 0..0, "SUB Form_Load\nERROR 6\nEND SUB\n")
.unwrap();
let before = p.sources().unwrap();
let target = t.0.join("F.frm");
p.save_file(id, Some(&Destination::new(&target))).unwrap();
// Der gemeinsame Speicherhook ergänzt DECLAREs; Speicherstand und Disk müssen dieselben physischen Zeilen liefern.
let before = p.sources().unwrap();
let disk = SourceLoader::default().load(&target).unwrap();
assert_eq!(
before.units[1]

View File

@@ -0,0 +1,449 @@
use crossterm::event::{Event, KeyCode as K, KeyEvent, KeyModifiers as M};
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
};
use tb_ide::{
app::{App, DialogKind, WindowKind},
commands::Command,
documents::{Destination, Project},
editor::{column, offset},
};
struct Temp(PathBuf);
impl Temp {
fn new() -> Self {
static N: AtomicUsize = AtomicUsize::new(0);
let p = std::env::temp_dir().join(format!(
"tb-editor-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
Self(p.canonicalize().unwrap())
}
fn app(&self) -> App {
let mut a = App::new(&self.0, self.0.join("options.ini"), (100, 30)).unwrap();
a.options.syntax_checking = false;
a
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn key(a: &mut App, k: K, m: M) {
a.handle(Event::Key(KeyEvent::new(k, m)));
}
fn plain(a: &mut App, k: K) {
key(a, k, M::NONE);
}
fn view(a: &App) -> tb_ide::documents::ViewId {
let WindowKind::Code(v) = a.active_window().unwrap().kind else {
panic!()
};
v
}
fn code(a: &App) -> &str {
a.project
.document(a.active_document().unwrap())
.unwrap()
.code()
}
fn field(a: &mut App, n: usize, text: &str) {
while a.dialog.as_ref().unwrap().focus != n {
plain(a, K::Tab);
}
key(a, K::Char('a'), M::CONTROL);
plain(a, K::Delete);
for c in text.chars() {
plain(a, K::Char(c));
}
}
fn toggle(a: &mut App, n: usize) {
while a.dialog.as_ref().unwrap().focus != n {
plain(a, K::Tab);
}
plain(a, K::Char(' '));
}
#[test]
fn cua_unicode_overwrite_wordstar_and_shared_undo() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("α\t\nzweite Zeile\n".into()));
assert_eq!(code(&a), "α\t\nzweite Zeile\n");
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::Char('k'), M::CONTROL);
plain(&mut a, K::Char('0'));
plain(&mut a, K::Right);
plain(&mut a, K::Right);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 3);
plain(&mut a, K::Down);
assert_eq!(
a.project.view(view(&a)).unwrap().cursor,
"α\t\n".len() + 8
);
key(&mut a, K::Char('q'), M::CONTROL);
plain(&mut a, K::Char('0'));
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 0);
let original = code(&a).to_owned();
key(&mut a, K::Char('q'), M::CONTROL);
plain(&mut a, K::Esc);
plain(&mut a, K::F(3));
assert_eq!(code(&a), original);
key(&mut a, K::Char('a'), M::CONTROL);
key(&mut a, K::Char('x'), M::CONTROL);
assert_eq!(code(&a), "");
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), original);
assert_eq!(
a.project.view(view(&a)).unwrap().selection(),
Some(0..original.len())
);
key(&mut a, K::Insert, M::CONTROL);
a.execute(Command::NewWindow);
key(&mut a, K::End, M::CONTROL);
key(&mut a, K::Insert, M::SHIFT);
assert_eq!(code(&a), original.repeat(2));
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), original);
key(&mut a, K::Home, M::CONTROL);
plain(&mut a, K::Insert);
plain(&mut a, K::Char('é'));
assert!(code(&a).starts_with("é\t"));
key(&mut a, K::Char('y'), M::CONTROL);
assert_eq!(code(&a), "zweite Zeile\n");
key(&mut a, K::Char('z'), M::CONTROL);
assert!(code(&a).starts_with("é\t"));
assert_eq!(column("α\t", 8), 10);
assert_eq!(offset("α\t", 8, 8), 3);
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::Right, M::SHIFT);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
terminal.draw(|f| a.render(f)).unwrap();
assert!(terminal
.backend()
.buffer()
.content
.iter()
.any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)));
}
#[test]
fn search_scope_direction_wrap_and_shortening_replace_all() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("one One stone one\n".into()));
key(&mut a, K::Home, M::CONTROL);
a.execute(Command::Replace);
field(&mut a, 0, "one");
field(&mut a, 1, "");
toggle(&mut a, 4);
while a.dialog.as_ref().unwrap().focus != 6 {
plain(&mut a, K::Tab);
}
plain(&mut a, K::Right);
plain(&mut a, K::Enter);
assert_eq!(code(&a), " stone \n");
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), "one One stone one\n");
a.execute(Command::Find);
field(&mut a, 0, "one");
toggle(&mut a, 3);
plain(&mut a, K::Enter);
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
plain(&mut a, K::F(3));
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(14..17));
plain(&mut a, K::F(3));
assert!(a.message.contains("Rücksprung"));
plain(&mut a, K::F(3));
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
key(&mut a, K::Char('\\'), M::CONTROL);
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(14..17));
a.execute(Command::Find);
toggle(&mut a, 2);
plain(&mut a, K::Enter);
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
a.execute(Command::Replace);
field(&mut a, 1, "x");
toggle(&mut a, 5);
while a.dialog.as_ref().unwrap().focus != 6 {
plain(&mut a, K::Tab);
}
plain(&mut a, K::Right);
plain(&mut a, K::Enter);
assert_eq!(code(&a), "x One stone one\n");
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), "one One stone one\n");
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
}
#[test]
fn line_leave_incomplete_invalid_disabled_and_current_revisions() {
let t = Temp::new();
let mut a = t.app();
a.options.syntax_checking = true;
for c in "print \"MiX\"".chars() {
plain(&mut a, K::Char(c));
}
plain(&mut a, K::Enter);
assert_eq!(code(&a), "PRINT \"MiX\"\n");
assert!(a.current_compilation().is_some());
a.handle(Event::Paste("SUB Fresh\n".into()));
assert!(a.dialog.is_none());
assert!(a.message.contains("unvollständig"), "{}", a.message);
assert!(a.current_compilation().is_none());
a.handle(Event::Paste("END SUB\n".into()));
assert!(a.current_compilation().is_some(), "{}", a.message);
a.handle(Event::Paste("PRINT )\n".into()));
assert!(matches!(
a.dialog.as_ref().map(|d| &d.kind),
Some(DialogKind::Diagnostics)
));
assert!(a.current_compilation().is_none());
plain(&mut a, K::Esc);
a.execute(Command::Undo);
assert!(a.compile_current().is_ok());
a.options.syntax_checking = false;
a.handle(Event::Paste("print )\n".into()));
assert!(a.dialog.is_none());
assert!(code(&a).ends_with("print )\n"));
assert!(a.current_compilation().is_none());
assert!(a.compile_current().is_err());
}
#[test]
fn procedures_and_shared_save_hook_preserve_drafts_and_frm_code() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("DEFINT A-Z\n".into()));
a.execute(Command::NewSub);
field(&mut a, 0, "Fresh");
plain(&mut a, K::Enter);
assert!(code(&a).contains("SUB Fresh"));
assert!(a.compile_current().is_ok());
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::F(2), M::SHIFT);
plain(&mut a, K::Right);
plain(&mut a, K::Enter);
assert!(a.project.view(view(&a)).unwrap().cursor > 0);
key(&mut a, K::F(2), M::CONTROL);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 0);
let id = a.active_document().unwrap();
let v = view(&a);
let cursor = code(&a).find("SUB Fresh").unwrap() + 4;
a.project.view_mut(v).unwrap().cursor = cursor;
a.project.view_mut(v).unwrap().anchor = Some(cursor + 5);
a.project
.save_file(id, Some(&Destination::new(t.0.join("main.bas"))))
.unwrap();
let saved = code(&a).to_owned();
assert!(saved.contains("DECLARE SUB FRESH"));
assert_eq!(
a.project.view(v).unwrap().cursor,
saved.find("SUB Fresh").unwrap() + 4
);
assert_eq!(
a.project.view(v).unwrap().selection().map(|r| &saved[r]),
Some("Fresh")
);
let revision = a.project.document(id).unwrap().revision();
a.project.save_file(id, None).unwrap();
assert_eq!(a.project.document(id).unwrap().revision(), revision);
assert_eq!(code(&a), saved);
let end = code(&a).len();
a.project.replace_text(id, end..end, "SUB Draft\n").unwrap();
let draft = code(&a).to_owned();
a.project.save_file(id, None).unwrap();
assert_eq!(fs::read_to_string(t.0.join("main.bas")).unwrap(), draft);
assert!(a.project.save_notices[0].contains("übersprungen"));
// Separater gültiger Projektstand für die Forms-Pflege.
a.project.replace_text(id, 0..draft.len(), &saved).unwrap();
let form = a.project.new_form("MainForm").unwrap();
a.project
.replace_text(form, 0..0, "SUB Button_Click\nEND SUB\n")
.unwrap();
a.project
.save_file(form, Some(&Destination::new(t.0.join("MainForm.frm"))))
.unwrap();
let text = fs::read_to_string(t.0.join("MainForm.frm")).unwrap();
assert!(text.contains("DECLARE SUB BUTTON_CLICK"), "{text}");
let parsed = tb_ui::frm::read_text("MainForm.frm", &text).unwrap();
assert_eq!(parsed.code, a.project.document(form).unwrap().code());
}
#[test]
fn includes_and_diagnostic_selection_use_original_physical_files() {
let t = Temp::new();
fs::write(t.0.join("one.bi"), "x$=1\n").unwrap();
fs::write(t.0.join("two.bi"), "y%=\"bad\"\n").unwrap();
fs::write(
t.0.join("main.bas"),
"'$INCLUDE: 'one.bi'\n'$INCLUDE: 'two.bi'\n",
)
.unwrap();
let mut a = t.app();
a.load_initial_project(t.0.join("main.bas")).unwrap();
assert!(a.compile_current().is_err());
let errors = a.editor.diagnostics.clone();
assert_eq!(errors.len(), 2);
for (i, d) in errors.iter().enumerate() {
assert_eq!(d.pos.line, 1);
a.goto_diagnostic(d).unwrap();
assert_eq!(
a.project
.document(a.active_document().unwrap())
.unwrap()
.source_path(),
t.0.join(if i == 0 { "one.bi" } else { "two.bi" })
);
}
a.load_initial_project(t.0.join("main.bas")).unwrap();
a.execute(Command::IncludedLines);
let original = code(&a).to_owned();
a.handle(Event::Paste("corruption".into()));
assert_eq!(code(&a), original);
assert!(a.editor.expansions.contains_key(&view(&a)));
a.execute(Command::IncludedLines);
a.execute(Command::IncludedFile);
assert!(a
.project
.document(a.active_document().unwrap())
.unwrap()
.source_path()
.ends_with("one.bi"));
let id = a.active_document().unwrap();
let len = code(&a).len();
a.project.replace_text(id, 0..len, "x$=\"ok\"\n").unwrap();
fs::write(t.0.join("two.bi"), "y%=2\n").unwrap(); // Open overlay remains authoritative.
let other = a.project.open_document(&t.0.join("two.bi")).unwrap();
let len = a.project.document(other).unwrap().code().len();
a.project.replace_text(other, 0..len, "y%=2\n").unwrap();
assert!(a.compile_current().is_ok());
let mut p = Project::open(&t.0.join("main.bas"), vec![]).unwrap();
let id = p.open_document(&t.0.join("one.bi")).unwrap();
assert_eq!(p.document(id).unwrap().code(), "x$=1\n");
}
#[test]
fn crlf_navigation_word_boundaries_readonly_and_normalization_keep_views() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("word next\r\n\tX\r\n".into()));
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::Right, M::CONTROL);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 5);
plain(&mut a, K::End);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 9);
plain(&mut a, K::Right);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 11);
plain(&mut a, K::Left);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 9);
plain(&mut a, K::Delete);
assert_eq!(code(&a), "word next界\tX\r\n");
a.execute(Command::Undo);
let id = a.active_document().unwrap();
let v = view(&a);
let second = a.project.open_view(id).unwrap();
a.project.view_mut(second).unwrap().cursor = 5;
a.project
.replace_text(id, 0..code(&a).len(), "print 1\n")
.unwrap();
a.project.view_mut(second).unwrap().cursor = 8;
a.project.view_mut(v).unwrap().cursor = 8;
a.options.syntax_checking = true;
plain(&mut a, K::Down);
assert_eq!(code(&a), "PRINT 1\n");
assert_eq!(a.project.view(second).unwrap().cursor, 6);
a.execute(Command::Undo);
assert_eq!(code(&a), "print 1\n");
assert_eq!(a.project.view(second).unwrap().cursor, 8);
a.options.syntax_checking = false;
a.execute(Command::IncludedLines);
let before = code(&a).to_owned();
for command in [
Command::Cut,
Command::Paste,
Command::Clear,
Command::Undo,
Command::NewSub,
Command::LoadText,
Command::Replace,
] {
a.execute(command);
assert!(a.dialog.is_none());
assert_eq!(code(&a), before);
assert!(a.message.contains("schreibgeschützt"));
}
let scroll = a.project.view(view(&a)).unwrap().scroll_line;
plain(&mut a, K::PageDown);
assert!(a.project.view(view(&a)).unwrap().scroll_line > scroll);
}
#[test]
fn external_include_revision_and_project_context_declarations() {
let t = Temp::new();
fs::write(
t.0.join("defs.bi"),
"DEFINT A-Z\nTYPE Pair\nX AS INTEGER\nEND TYPE\nCONST N=1\n",
)
.unwrap();
fs::write(
t.0.join("main.bas"),
"'$INCLUDE: 'defs.bi'\nSUB Work(p AS Pair, n)\np.x=n+N\nEND SUB\n",
)
.unwrap();
let mut a = t.app();
a.load_initial_project(t.0.join("main.bas")).unwrap();
assert!(a.compile_current().is_ok());
assert!(a.current_compilation().is_some());
fs::write(
t.0.join("defs.bi"),
"DEFINT A-Z\nTYPE Pair\nX AS INTEGER\nEND TYPE\nCONST N=2\n",
)
.unwrap();
// Geöffnete Include-Dokumente sind maßgeblich; externe Änderungen überschreiben keinen Puffer.
assert!(a.current_compilation().is_some());
let include = a.project.find_document(&t.0.join("defs.bi")).unwrap();
let len = a.project.document(include).unwrap().code().len();
a.project
.replace_text(
include,
0..len,
&fs::read_to_string(t.0.join("defs.bi")).unwrap(),
)
.unwrap();
assert!(a.current_compilation().is_none());
assert!(a.current_diagnostics().is_none());
assert!(a.compile_current().is_ok());
let id = a.active_document().unwrap();
a.project.save_file(id, None).unwrap();
assert!(
code(&a).contains("DECLARE SUB WORK (P AS PAIR, N%)"),
"{} {:?}",
code(&a),
a.project.save_notices
);
assert!(a.project.save_notices.is_empty());
assert!(a.compile_current().is_ok());
a.execute(Command::NewFunction);
field(&mut a, 0, "Fresh");
plain(&mut a, K::Enter);
assert!(code(&a).contains("FUNCTION Fresh%"), "{}", code(&a));
assert!(a.compile_current().is_ok());
let other = a.project.new_module("Other").unwrap();
a.project
.replace_text(other, 0..0, "SUB Taken\nEND SUB\n")
.unwrap();
a.execute(Command::NewSub);
field(&mut a, 0, "Taken");
plain(&mut a, K::Enter);
assert!(a.dialog.as_ref().unwrap().error.contains("bereits"));
plain(&mut a, K::Esc);
// Ein erst nach dem Öffnen eingefügtes Include wird bis zum Öffnen direkt vom Loader gelesen.
fs::write(t.0.join("late.bi"), "CONST LATE=1\n").unwrap();
a.project
.replace_text(id, 0..0, "'$INCLUDE: 'late.bi'\n")
.unwrap();
assert!(a.compile_current().is_ok());
fs::remove_file(t.0.join("late.bi")).unwrap();
assert!(a.current_compilation().is_none());
assert!(a.compile_current().is_err());
assert!(a.current_diagnostics().is_none());
}