From df85a4b7b25dc94636dd8a7c5c777507519db6b6 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 6 Sep 2026 16:28:16 +0200 Subject: [PATCH] Phase 5: IDE-Rahmen implementieren, synchronisieren und archivieren --- Cargo.lock | 1 + README.md | 4 + crates/tb-ide/Cargo.toml | 1 + crates/tb-ide/src/app.rs | 1757 +++++++++++++++++ crates/tb-ide/src/commands.rs | 337 ++++ crates/tb-ide/src/documents.rs | 29 +- crates/tb-ide/src/export.rs | 97 + crates/tb-ide/src/lib.rs | 6 + crates/tb-ide/src/main.rs | 38 +- crates/tb-ide/src/options.rs | 187 ++ crates/tb-ide/src/render.rs | 569 ++++++ crates/tb-ide/src/terminal.rs | 102 + crates/tb-ide/tests/app.rs | 693 +++++++ docs/ide-bedienung.md | 164 ++ .../phase-5-uebersicht.md | 2 +- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 4 +- .../specs/ide-oberflaeche/spec.md | 0 .../tasks.md | 34 +- .../verification.md | 142 ++ .../proposal.md | 2 +- .../proposal.md | 2 +- .../phase-5-05-formular-designer/proposal.md | 2 +- .../phase-5-07-hilfesystem/proposal.md | 2 +- .../proposal.md | 2 +- openspec/specs/ide-oberflaeche/spec.md | 72 + 27 files changed, 4215 insertions(+), 34 deletions(-) create mode 100644 crates/tb-ide/src/app.rs create mode 100644 crates/tb-ide/src/commands.rs create mode 100644 crates/tb-ide/src/export.rs create mode 100644 crates/tb-ide/src/options.rs create mode 100644 crates/tb-ide/src/render.rs create mode 100644 crates/tb-ide/src/terminal.rs create mode 100644 crates/tb-ide/tests/app.rs create mode 100644 docs/ide-bedienung.md rename openspec/changes/{phase-5-02-ide-rahmen => archive/2026-09-06-phase-5-02-ide-rahmen}/.openspec.yaml (100%) rename openspec/changes/{phase-5-02-ide-rahmen => archive/2026-09-06-phase-5-02-ide-rahmen}/design.md (100%) rename openspec/changes/{phase-5-02-ide-rahmen => archive/2026-09-06-phase-5-02-ide-rahmen}/proposal.md (83%) rename openspec/changes/{phase-5-02-ide-rahmen => archive/2026-09-06-phase-5-02-ide-rahmen}/specs/ide-oberflaeche/spec.md (100%) rename openspec/changes/{phase-5-02-ide-rahmen => archive/2026-09-06-phase-5-02-ide-rahmen}/tasks.md (70%) create mode 100644 openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/verification.md create mode 100644 openspec/specs/ide-oberflaeche/spec.md diff --git a/Cargo.lock b/Cargo.lock index 1a08d8f..bab6084 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -615,6 +615,7 @@ dependencies = [ "tb-runtime", "tb-ui", "tb-vm", + "unicode-width 0.2.0", ] [[package]] diff --git a/README.md b/README.md index e14bd71..d794679 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ cargo run -p tb-ide # tb — IDE (TUI) Benötigt wird nur eine aktuelle stabile Rust-Toolchain. +Die [IDE-Bedienung](docs/ide-bedienung.md) beschreibt Menüs, Fenster, +Speichern, Optionen und die Exportdialoge einschließlich ihres aktuellen +Umsetzungsstands. + ## Lizenz MIT — siehe [LICENSE](LICENSE). diff --git a/crates/tb-ide/Cargo.toml b/crates/tb-ide/Cargo.toml index 60c2e01..7f70e9e 100644 --- a/crates/tb-ide/Cargo.toml +++ b/crates/tb-ide/Cargo.toml @@ -18,3 +18,4 @@ tb-ui.workspace = true ratatui.workspace = true crossterm.workspace = true anyhow.workspace = true +unicode-width.workspace = true diff --git a/crates/tb-ide/src/app.rs b/crates/tb-ide/src/app.rs new file mode 100644 index 0000000..0315524 --- /dev/null +++ b/crates/tb-ide/src/app.rs @@ -0,0 +1,1757 @@ +use crate::{ + commands::{self, Command, Menu}, + documents::{Decision, Destination, DocumentId, Project, SavePlan, StartupRemoval, ViewId}, + export::{Artifact, ExportRequest, ExportStatus}, + options::{Options, ELEMENTS}, +}; +use anyhow::{anyhow, ensure, Result}; +use crossterm::event::{ + Event, KeyCode as K, KeyEvent, KeyEventKind, KeyModifiers as M, ModifierKeyCode, MouseButton, + MouseEventKind, +}; +use ratatui::layout::Rect; +use std::path::{Path, PathBuf}; +use tb_vm::project_io::Content; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Environment, + Designer, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Execution { + Idle, + Paused, + Running, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowKind { + Code(ViewId), + Project, + Output, + Calls, + Debug, + Immediate, + Help, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowState { + Normal, + Minimized, + Maximized, +} +#[derive(Debug, Clone)] +pub struct Window { + pub id: u64, + pub kind: WindowKind, + pub normal: Rect, + pub state: WindowState, +} +#[derive(Debug, Clone)] +pub enum FieldValue { + Text(String), + Choice { items: Vec, selected: usize }, + Toggle(bool), +} +#[derive(Debug, Clone)] +pub struct Field { + pub label: String, + pub value: FieldValue, + pub cursor: usize, + pub selected: bool, +} +impl Field { + pub fn text(label: impl Into, text: impl Into) -> Self { + let text = text.into(); + Self { + label: label.into(), + cursor: text.len(), + value: FieldValue::Text(text), + selected: true, + } + } + pub fn choice(label: impl Into, items: Vec, selected: usize) -> Self { + Self { + label: label.into(), + value: FieldValue::Choice { selected, items }, + cursor: 0, + selected: false, + } + } + pub fn toggle(label: impl Into, value: bool) -> Self { + Self { + label: label.into(), + value: FieldValue::Toggle(value), + cursor: 0, + selected: false, + } + } + pub fn string(&self) -> String { + match &self.value { + FieldValue::Text(s) => s.clone(), + FieldValue::Choice { items, selected } => { + items.get(*selected).cloned().unwrap_or_default() + } + FieldValue::Toggle(v) => v.to_string(), + } + } + pub fn index(&self) -> usize { + match self.value { + FieldValue::Choice { selected, .. } => selected, + _ => 0, + } + } + pub fn flag(&self) -> bool { + matches!(self.value, FieldValue::Toggle(true)) + } + fn key(&mut self, key: KeyEvent) { + match &mut self.value { + FieldValue::Toggle(value) => { + if matches!(key.code, K::Char(' ') | K::Left | K::Right) { + *value = !*value + } + } + FieldValue::Choice { items, selected } => { + if !items.is_empty() { + match key.code { + K::Left => *selected = (*selected + items.len() - 1) % items.len(), + K::Right | K::Char(' ') => *selected = (*selected + 1) % items.len(), + _ => {} + } + } + } + FieldValue::Text(s) => { + if key.modifiers.contains(M::CONTROL) && key.code == K::Char('a') { + self.selected = true; + return; + } + match key.code { + K::Char(c) if !key.modifiers.intersects(M::CONTROL | M::ALT) => { + if self.selected { + s.clear(); + self.cursor = 0; + } + s.insert(self.cursor, c); + self.cursor += c.len_utf8(); + } + K::Backspace => { + if self.selected { + s.clear(); + self.cursor = 0; + } else if self.cursor > 0 { + let p = s[..self.cursor].char_indices().last().unwrap().0; + s.drain(p..self.cursor); + self.cursor = p; + } + } + K::Delete => { + if self.selected { + s.clear(); + self.cursor = 0; + } else if self.cursor < s.len() { + let n = s[self.cursor..].chars().next().unwrap().len_utf8(); + s.drain(self.cursor..self.cursor + n); + } + } + K::Left => { + self.cursor = s[..self.cursor] + .char_indices() + .last() + .map(|(i, _)| i) + .unwrap_or(0); + } + K::Right => { + self.cursor += s[self.cursor..] + .chars() + .next() + .map(char::len_utf8) + .unwrap_or(0); + } + K::Home => self.cursor = 0, + K::End => self.cursor = s.len(), + _ => return, + } + self.selected = false; + } + } + } +} +#[derive(Debug, Clone)] +pub enum AfterSave { + Stay, + Exit, + NewProject, + OpenProject(PathBuf), +} +#[derive(Debug, Clone)] +pub enum DialogKind { + Browse { + return_to: Box, + field: usize, + entries: Vec, + }, + Message, + OpenProject, + NewModule, + AddFile, + ChooseCode, + ChooseForm, + Remove, + Startup, + Save { + project: bool, + ids: Vec, + after: AfterSave, + }, + Dirty(AfterSave), + LoadText, + SaveText, + Display, + Paths, + RightMouse, + SaveOptions, + Export(Artifact), +} +#[derive(Debug, Clone)] +pub struct Dialog { + pub title: String, + pub kind: DialogKind, + pub fields: Vec, + pub focus: usize, + pub error: String, + pub request: Option, + pub export_status: ExportStatus, +} +impl Dialog { + fn new(title: impl Into, kind: DialogKind, fields: Vec) -> Self { + Self { + title: title.into(), + kind, + fields, + focus: 0, + error: String::new(), + request: None, + export_status: ExportStatus::Ready, + } + } +} +#[derive(Debug, Clone, Copy)] +pub enum Hit { + Command(Command), + Menu(usize), + MenuItem(usize), + Window(u64), + ProjectMember(usize), + ProjectView(bool), + DialogField(usize), + DialogSubmit, + DialogCancel, +} + +pub struct App { + pub project: Project, + pub options: Options, + pub mode: Mode, + pub execution: Execution, + pub windows: Vec, + pub active: u64, + pub size: (u16, u16), + pub menu: Option<(usize, usize)>, + pub control_menu: bool, + pub dialog: Option, + pub properties: bool, + pub value_focus: bool, + pub selected_member: usize, + pub message: String, + pub quit: bool, + pub last_command: Option, + pub hits: Vec<(Rect, Hit)>, + pub basic_events: Vec, + pub last_export: Option<(ExportRequest, ExportStatus)>, + pub config_path: PathBuf, + pub saved_options: Options, + config_disk: Option>, + next_window: u64, + geometry_action: Option<(bool, Rect)>, + base: PathBuf, +} +impl App { + pub fn load_initial_project(&mut self, path: PathBuf) -> Result<()> { + self.finish_change(AfterSave::OpenProject(path)) + } + pub fn new(base: &Path, config_path: PathBuf, size: (u16, u16)) -> Result { + let mut project = Project::new(base)?; + let doc = untitled(&mut project)?; + let (options, errors, config_disk) = Options::load(&config_path); + project.include_paths = options.include_paths.clone(); + let mut app = Self { + project, + options: options.clone(), + saved_options: options, + config_path, + config_disk, + base: tb_vm::project_io::identity(base)?, + mode: Mode::Environment, + execution: Execution::Idle, + windows: vec![], + active: 0, + size, + menu: None, + control_menu: false, + dialog: None, + properties: false, + value_focus: false, + selected_member: 0, + message: format!( + "Terminal Basic {} · Copyright {} · {}", + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_AUTHORS"), + errors.join("; ") + ), + quit: false, + last_command: None, + hits: vec![], + basic_events: vec![], + last_export: None, + next_window: 1, + geometry_action: None, + }; + let width = size.0.max(80); + let height = size.1.max(25); + let view = app.project.open_view(doc)?; + let code = app.add_window( + WindowKind::Code(view), + Rect::new(0, 1, width.saturating_sub(32), height - 3), + ); + app.add_window( + WindowKind::Project, + Rect::new(width - 32, 1, 32, height - 3), + ); + app.active = code; + Ok(app) + } + pub fn area(&self) -> Rect { + Rect::new( + 0, + 1, + self.size.0, + self.size + .1 + .saturating_sub(if self.mode == Mode::Designer { 1 } else { 2 }), + ) + } + pub fn rect(&self, w: &Window) -> Rect { + let area = self.area(); + match w.state { + WindowState::Maximized => area, + WindowState::Minimized => Rect::new( + 0, + area.bottom().saturating_sub(2), + 24.min(area.width), + 2.min(area.height), + ), + WindowState::Normal => clamp(w.normal, area), + } + } + fn add_window(&mut self, kind: WindowKind, normal: Rect) -> u64 { + let id = self.next_window; + self.next_window += 1; + self.windows.push(Window { + id, + kind, + normal, + state: WindowState::Normal, + }); + self.active = id; + id + } + pub fn active_window(&self) -> Option<&Window> { + self.windows.iter().find(|w| w.id == self.active) + } + pub fn active_document(&self) -> Option { + match self.active_window()?.kind { + WindowKind::Code(v) => self.project.view(v).ok().map(|v| v.document()), + _ => self.project.members().get(self.selected_member).copied(), + } + } + pub fn title(&self, w: &Window) -> String { + match w.kind { + WindowKind::Code(v) => { + let doc = self + .project + .document(self.project.view(v).unwrap().document()) + .unwrap(); + let name = doc + .path() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| { + doc.source_path() + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }); + format!( + "[{}] {}{}", + w.id, + name, + if doc.is_dirty() { " *" } else { "" } + ) + } + WindowKind::Project => format!( + "Project: {}", + self.project + .path() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Untitled".into()) + ) + .trim_end() + .to_string(), + _ => format!("{:?}", w.kind), + } + } + pub fn menus(&self) -> Vec { + commands::menus(self.mode == Mode::Designer) + } + pub fn window_commands(&self) -> Vec<(String, Command)> { + self.windows + .iter() + .filter(|w| matches!(w.kind, WindowKind::Code(_))) + .filter(|w| self.mode!=Mode::Designer || matches!(w.kind,WindowKind::Code(v) if self.project.view(v).ok().and_then(|v|self.project.document(v.document()).ok()).is_some_and(|d|matches!(d.content(),Content::Form(_))))) + .map(|w| { + ( + format!( + "{}{}{}", + if w.id == self.active { "• " } else { " " }, + if self.mode==Mode::Designer {"Form: "}else{""}, + self.title(w) + ), + Command::FocusWindow(w.id), + ) + }) + .collect() + } + pub fn availability(&self, command: Command) -> Option { + if let Some(phase) = command.feature_phase() { + 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(_)) + ) + { + return Some("Kein Codefenster aktiv".into()); + } + if matches!( + command, + SaveFile | SaveAs | LoadText | SaveText | Undo | NewWindow + ) && self.active_document().is_none() + { + return Some("Kein Dokument ausgewählt".into()); + } + if matches!(command, Form) + && !self + .project + .documents() + .any(|(_, d)| matches!(d.content(), Content::Form(_))) + { + return Some("Kein Formular vorhanden".into()); + } + if matches!(command, RemoveFile | Startup) && self.project.members().is_empty() { + return Some("Kein Projektmitglied vorhanden".into()); + } + if matches!( + command, + CloseWindow | MoveWindow | SizeWindow | Minimize | Maximize | Restore | ControlMenu + ) && self.active_window().is_none() + { + return Some("Kein Fenster aktiv".into()); + } + None + } + pub fn execute(&mut self, command: Command) { + self.last_command = Some(command); + if let Some(reason) = self.availability(command) { + self.message = reason; + return; + } + self.menu = None; + self.control_menu = false; + if let Err(e) = self.action(command) { + self.message = format!("{e:#}"); + } + } + fn action(&mut self, command: Command) -> Result<()> { + use Command::*; + let id = self.active_document(); + match command { + NewProject => self.change_project(AfterSave::NewProject)?, + OpenProject => self.open_dialog( + "Open Project", + DialogKind::OpenProject, + vec![Field::text("Datei", self.source_default())], + ), + Exit => self.change_project(AfterSave::Exit)?, + NewModule => self.open_dialog( + "New Module", + DialogKind::NewModule, + vec![Field::text("Name", "Module1")], + ), + NewForm => { + let mut n = 1; + while self + .project + .loader()? + .resolve(self.project.directory(), &format!("Form{n}.frm")) + .is_ok() + || self.project.documents().any(|(_, d)| { + d.source_path() + .file_stem() + .is_some_and(|s| s.eq_ignore_ascii_case(format!("Form{n}").as_str())) + }) + { + n += 1; + } + let id = self.project.new_form(&format!("Form{n}"))?; + self.show_document(id, true)?; + } + AddFile => self.open_dialog( + "Add File", + DialogKind::AddFile, + vec![Field::text("Datei", self.source_default())], + ), + SaveFile => { + let id = id.unwrap(); + if self.project.document(id)?.path().is_none() + || self.project.save_file(id, None).is_err() + { + self.save_dialog(false, vec![id], AfterSave::Stay)?; + } else { + self.message = "Datei gespeichert".into(); + } + } + SaveAs => self.save_dialog(false, vec![id.unwrap()], AfterSave::Stay)?, + SaveProject => self.save_dialog( + true, + self.project.documents().map(|(id, _)| id).collect(), + AfterSave::Stay, + )?, + RemoveFile => { + let ids = self.project.members(); + let names = self.names(&ids); + let mut replacements = vec!["Bisheriger Standard".into()]; + replacements.extend(names.clone()); + self.open_dialog( + "Remove File", + DialogKind::Remove, + vec![ + Field::choice("Mitglied", names, self.selected_member.min(ids.len() - 1)), + Field::choice("Ersatz für Startdatei", replacements, 0), + ], + ); + } + Startup => { + let ids = self.project.members(); + let mut names = vec!["Bisheriger Standard".into()]; + names.extend(self.names(&ids)); + let selected = self + .project + .startup() + .and_then(|id| ids.iter().position(|i| *i == id)) + .map(|n| n + 1) + .unwrap_or(0); + self.open_dialog( + "Set Start-up File", + DialogKind::Startup, + vec![Field::choice("Startdatei", names, selected)], + ); + } + Code | Form => { + let ids: Vec<_> = self + .project + .documents() + .filter(|(_, d)| command == Code || matches!(d.content(), Content::Form(_))) + .map(|(id, _)| id) + .collect(); + let selected = id + .and_then(|id| ids.iter().position(|i| *i == id)) + .unwrap_or(0); + let names = self.names(&ids); + self.open_dialog( + if command == Code { "Code" } else { "Form" }, + if command == Code { + DialogKind::ChooseCode + } else { + DialogKind::ChooseForm + }, + vec![Field::choice("Dokument", names, selected)], + ); + } + LoadText => self.open_dialog( + "Load Text", + DialogKind::LoadText, + vec![Field::text("Datei", self.source_default())], + ), + SaveText => self.open_dialog( + "Save Text", + DialogKind::SaveText, + vec![ + Field::text("Datei", self.output_default("text.txt")), + Field::toggle("Bestehendes Ziel überschreiben", false), + ], + ), + Undo => { + self.project.undo(id.unwrap())?; + } + NewWindow => { + let view = self.project.open_view(id.unwrap())?; + self.add_window(WindowKind::Code(view), self.cascade()); + } + Arrange => self.arrange(), + NextWindow => self.cycle(1), + PreviousWindow => self.cycle(-1), + CloseWindow => { + if let Some(i) = self.windows.iter().position(|w| w.id == self.active) { + let w = self.windows.remove(i); + if let WindowKind::Code(v) = w.kind { + self.project.close_view(v)?; + } + self.active = self.windows.first().map(|w| w.id).unwrap_or(0); + } + } + MoveWindow | SizeWindow => { + if let Some(w) = self.windows.iter().find(|w| w.id == self.active) { + self.geometry_action = Some((command == SizeWindow, w.normal)); + self.message = "Pfeile ändern Fenster; Enter bestätigt, Esc verwirft".into(); + } + } + Minimize | Maximize | Restore => { + if let Some(w) = self.windows.iter_mut().find(|w| w.id == self.active) { + w.state = match command { + Minimize => WindowState::Minimized, + Maximize => WindowState::Maximized, + _ => WindowState::Normal, + }; + } + } + ControlMenu => { + self.control_menu = true; + self.menu = Some((0, 0)); + } + FocusWindow(id) => { + if self.windows.iter().any(|w| w.id == id) { + self.active = id; + } + } + Calls | Debug | HelpWindow | Immediate | Output | Project => { + let kind = match command { + Calls => WindowKind::Calls, + Debug => WindowKind::Debug, + HelpWindow => WindowKind::Help, + Immediate => WindowKind::Immediate, + Output => WindowKind::Output, + _ => WindowKind::Project, + }; + self.show_tool(kind); + } + MenuBar => { + self.properties = !self.properties; + self.value_focus = false; + self.menu = if self.properties { None } else { Some((0, 0)) }; + } + Display => { + let mut fields = vec![]; + for (i, name) in ELEMENTS.iter().enumerate() { + fields.push(Field::choice( + format!("{name} Vordergrund"), + (0..16).map(|n| n.to_string()).collect(), + self.options.colors[i].0 as usize, + )); + fields.push(Field::choice( + format!("{name} Hintergrund"), + (0..16).map(|n| n.to_string()).collect(), + self.options.colors[i].1 as usize, + )); + } + fields.extend([ + Field::text("Desktopzeichen", self.options.desktop.to_string()), + Field::text("Tabweite", self.options.tab_width.to_string()), + ]); + self.open_dialog("Display", DialogKind::Display, fields); + } + Paths => self.open_dialog( + "Set Paths", + DialogKind::Paths, + vec![ + Field::text("Quellen", self.options.source_dir.display().to_string()), + Field::text("Ausgaben", self.options.output_dir.display().to_string()), + Field::text( + "Include-Pfade (| getrennt)", + self.options + .include_paths + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join("|"), + ), + ], + ), + RightMouse => self.open_dialog( + "Right Mouse", + DialogKind::RightMouse, + vec![Field::toggle( + "Rechtsklick öffnet Kontext-Hilfe", + self.options.right_help, + )], + ), + SaveOptions => self.open_dialog("Save Options", DialogKind::SaveOptions, vec![]), + SyntaxChecking => { + self.options.syntax_checking = !self.options.syntax_checking; + self.message = format!("Syntax Checking: {}", self.options.syntax_checking); + } + About => self.open_dialog( + format!( + "Terminal Basic {} · Copyright {}", + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_AUTHORS") + ), + DialogKind::Message, + vec![], + ), + MakeExe | MakeLibrary => { + let artifact = if command == MakeExe { + Artifact::Executable + } else { + Artifact::Library + }; + let systems = vec!["linux".into(), "macos".into(), "windows".into()]; + let selected = systems + .iter() + .position(|s| s == std::env::consts::OS) + .unwrap_or(0); + let architectures = vec!["x86_64".into(), "aarch64".into()]; + let arch = architectures + .iter() + .position(|s| s == std::env::consts::ARCH) + .unwrap_or(0); + self.open_dialog( + if command == MakeExe { + "Make EXE File" + } else { + "Make Library" + }, + DialogKind::Export(artifact), + vec![ + Field::text("Artefakt", artifact.label()), + Field::choice("Zielsystem", systems, selected), + Field::choice("Architektur", architectures, arch), + Field::text( + "Ausgabepfad", + self.output_default(if artifact == Artifact::Executable { + "program" + } else { + "library" + }), + ), + Field::toggle("Bestehendes Ziel überschreiben", false), + ], + ); + } + _ => {} + } + Ok(()) + } + fn names(&self, ids: &[DocumentId]) -> Vec { + ids.iter() + .map(|id| { + self.project + .document(*id) + .unwrap() + .source_path() + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }) + .collect() + } + fn source_default(&self) -> String { + if self.options.source_dir.as_os_str().is_empty() { + self.base.display().to_string() + } else { + self.options.source_dir.display().to_string() + } + } + fn output_default(&self, name: &str) -> String { + if self.options.output_dir.as_os_str().is_empty() { + self.base.join(name) + } else { + self.options.output_dir.join(name) + } + .display() + .to_string() + } + fn open_dialog(&mut self, title: impl Into, kind: DialogKind, fields: Vec) { + self.dialog = Some(Dialog::new(title, kind, fields)); + } + fn cascade(&self) -> Rect { + let offset = (self.windows.len() % 5) as u16 * 2; + clamp(Rect::new(offset, 1 + offset, 60, 16), self.area()) + } + fn show_tool(&mut self, kind: WindowKind) { + if let Some(w) = self.windows.iter_mut().find(|w| w.kind == kind) { + self.active = w.id; + w.state = WindowState::Normal; + } else { + self.add_window(kind, self.cascade()); + } + } + fn show_document(&mut self, id: DocumentId, form: bool) -> Result<()> { + if form { + ensure!( + matches!(self.project.document(id)?.content(), Content::Form(_)), + "Kein Formular" + ); + } + if let Some(w)=self.windows.iter_mut().find(|w|matches!(w.kind,WindowKind::Code(v) if self.project.view(v).is_ok_and(|v|v.document()==id))){self.active=w.id;w.state=WindowState::Normal;}else{let view=self.project.open_view(id)?;self.add_window(WindowKind::Code(view),self.cascade());} + self.mode = if form { + Mode::Designer + } else { + Mode::Environment + }; + self.properties = form; + self.value_focus = false; + Ok(()) + } + fn cycle(&mut self, direction: isize) { + if self.windows.is_empty() { + return; + } + let i = self + .windows + .iter() + .position(|w| w.id == self.active) + .unwrap_or(0); + let i = (i as isize + direction).rem_euclid(self.windows.len() as isize) as usize; + self.active = self.windows[i].id; + } + fn arrange(&mut self) { + let area = self.area(); + let n = self.windows.len(); + if n == 0 { + return; + } + let cols = (n as f64).sqrt().ceil() as u16; + let rows = (n as u16).div_ceil(cols); + for (i, w) in self.windows.iter_mut().enumerate() { + let col = i as u16 % cols; + let row = i as u16 / cols; + let x = area.width * col / cols; + let right = area.width * (col + 1) / cols; + let y = area.height * row / rows; + let bottom = area.height * (row + 1) / rows; + w.normal = Rect::new(x, area.y + y, right - x, bottom - y); + w.state = WindowState::Normal; + } + } + fn change_project(&mut self, after: AfterSave) -> Result<()> { + if self.project.is_dirty() { + self.open_dialog( + "Ungespeicherte Änderungen", + DialogKind::Dirty(after), + vec![Field::choice( + "Entscheidung", + vec!["Abbrechen".into(), "Speichern".into(), "Verwerfen".into()], + 0, + )], + ); + } else { + self.finish_change(after)?; + } + Ok(()) + } + fn finish_change(&mut self, after: AfterSave) -> Result<()> { + match after { + AfterSave::Stay => return Ok(()), + AfterSave::Exit => { + self.quit = true; + return Ok(()); + } + AfterSave::NewProject => { + self.project.new_project(Decision::Discard)?; + untitled(&mut self.project)?; + } + AfterSave::OpenProject(path) => { + self.project.open_project(&path, Decision::Discard)?; + } + } + self.base = self.project.directory().to_path_buf(); + self.windows.clear(); + self.active = 0; + self.selected_member = 0; + self.mode = Mode::Environment; + self.properties = false; + if let Some(id) = self.project.members().first().copied() { + self.show_document(id, false)?; + } + let active = self.active; + self.show_tool(WindowKind::Project); + if active != 0 { + self.active = active; + } + self.arrange(); + Ok(()) + } + fn save_dialog(&mut self, project: bool, ids: Vec, after: AfterSave) -> Result<()> { + let mut fields = vec![]; + if project { + fields.push(Field::text( + "Projekt (.mak)", + self.project + .path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| self.output_default("project.mak")), + )); + fields.push(Field::toggle("Projektziel überschreiben", false)); + } + for id in &ids { + let doc = self.project.document(*id)?; + fields.push(Field::text( + self.names(&[*id])[0].clone(), + doc.path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| doc.source_path().display().to_string()), + )); + fields.push(Field::toggle("Dateiziel überschreiben", false)); + } + self.open_dialog( + if project { + "Save Project" + } else { + "Save File As" + }, + DialogKind::Save { + project, + ids, + after, + }, + fields, + ); + Ok(()) + } + fn submit_dialog(&mut self) { + let Some(mut d) = self.dialog.take() else { + return; + }; + match self.submit(&mut d) { + Ok(keep) => { + if keep { + self.dialog = Some(d); + } + } + Err(e) => { + d.error = format!("{e:#}"); + self.dialog = Some(d); + } + } + } + fn submit(&mut self, d: &mut Dialog) -> Result { + match d.kind.clone() { + DialogKind::Browse { + mut return_to, + field, + entries, + } => { + let path = entries + .get(d.fields[0].index()) + .ok_or_else(|| anyhow!("Keine Datei ausgewählt"))?; + if path.is_dir() { + self.dialog = Some(Self::browser(*return_to, field, path)?); + } else { + return_to.fields[field] = Field::text( + return_to.fields[field].label.clone(), + path.display().to_string(), + ); + return_to.request = None; + self.dialog = Some(*return_to); + } + } + DialogKind::Message => {} + DialogKind::OpenProject => { + let path = self.base.join(d.fields[0].string()); + Project::open(&path, self.options.include_paths.clone())?; + self.change_project(AfterSave::OpenProject(path))?; + } + DialogKind::NewModule => { + let id = self.project.new_module(&d.fields[0].string())?; + self.show_document(id, false)?; + } + DialogKind::AddFile => { + let id = self.project.add_file(Path::new(&d.fields[0].string()))?; + self.show_document(id, false)?; + } + DialogKind::ChooseCode | DialogKind::ChooseForm => { + let form = matches!(d.kind, DialogKind::ChooseForm); + let ids: Vec<_> = self + .project + .documents() + .filter(|(_, d)| !form || matches!(d.content(), Content::Form(_))) + .map(|(id, _)| id) + .collect(); + let id = *ids + .get(d.fields[0].index()) + .ok_or_else(|| anyhow!("Kein Dokument"))?; + self.show_document(id, form)?; + } + DialogKind::Remove => { + let ids = self.project.members(); + let id = ids[d.fields[0].index()]; + let replacement = d.fields[1].index(); + self.project.remove_file( + id, + Some(if replacement == 0 { + StartupRemoval::Default + } else { + StartupRemoval::Replace(ids[replacement - 1]) + }), + )?; + self.selected_member = self + .selected_member + .min(self.project.members().len().saturating_sub(1)); + } + DialogKind::Startup => { + let ids = self.project.members(); + let choice = d.fields[0].index(); + self.project.set_startup(if choice == 0 { + None + } else { + Some(ids[choice - 1]) + })?; + } + DialogKind::Dirty(after) => match d.fields[0].index() { + 1 => self.save_dialog( + true, + self.project.documents().map(|(id, _)| id).collect(), + after, + )?, + 2 => self.finish_change(after)?, + _ => {} + }, + DialogKind::Save { + project, + ids, + after, + } => { + let mut plan = SavePlan::default(); + let mut offset = 0; + if project { + plan.project = Some(Destination { + path: d.fields[0].string().into(), + overwrite: d.fields[1].flag(), + }); + offset = 2; + } + for (i, id) in ids.iter().enumerate() { + let path = PathBuf::from(d.fields[offset + 2 * i].string()); + let doc = self.project.document(*id)?; + // Unveränderte Mitglieder brauchen keinen erneuten Schreibzugriff (insbesondere Binärimporte). + if !project || doc.is_dirty() || doc.path() != Some(path.as_path()) { + plan.files.insert( + *id, + Destination { + path, + overwrite: d.fields[offset + 2 * i + 1].flag(), + }, + ); + } + } + if project { + self.project.save_project(&plan)?; + self.base = self.project.path().unwrap().parent().unwrap().to_path_buf(); + } else { + self.project.save_file(ids[0], plan.files.get(&ids[0]))?; + } + self.message = "Gespeichert".into(); + 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()))?; + } + DialogKind::SaveText => { + let (id, _) = self.code_cursor()?; + self.project.save_text( + id, + None, + &Destination { + path: d.fields[0].string().into(), + overwrite: d.fields[1].flag(), + }, + )?; + } + DialogKind::Display => { + let mut options = self.options.clone(); + for i in 0..7 { + options.colors[i] = ( + d.fields[2 * i].index() as u8, + d.fields[2 * i + 1].index() as u8, + ); + } + let s = d.fields[14].string(); + ensure!(s.chars().count() == 1, "Ein Desktopzeichen erforderlich"); + options.desktop = s.chars().next().unwrap(); + options.tab_width = d.fields[15].string().parse()?; + options.validate()?; + self.options = options; + } + DialogKind::Paths => { + let mut options = self.options.clone(); + let path = |s: String| { + if s.is_empty() { + PathBuf::new() + } else { + self.base.join(s) + } + }; + options.source_dir = path(d.fields[0].string()); + options.output_dir = path(d.fields[1].string()); + options.include_paths = d.fields[2] + .string() + .split('|') + .filter(|s| !s.is_empty()) + .map(|s| path(s.into())) + .collect(); + options.validate()?; + self.project.include_paths = options.include_paths.clone(); + self.options = options; + } + DialogKind::RightMouse => self.options.right_help = d.fields[0].flag(), + DialogKind::SaveOptions => { + self.config_disk = Some( + self.options + .save(&self.config_path, self.config_disk.as_deref())?, + ); + self.saved_options = self.options.clone(); + self.message = format!("Optionen gespeichert: {}", self.config_path.display()); + } + DialogKind::Export(artifact) => { + let request = ExportRequest::validate( + &self.project, + artifact, + &d.fields[1].string(), + &d.fields[2].string(), + &Destination { + path: d.fields[3].string().into(), + overwrite: d.fields[4].flag(), + }, + )?; + d.request = Some(request); + d.export_status = ExportStatus::Ready; + d.error = crate::export::UNAVAILABLE.into(); + return Ok(true); + } + } + Ok(false) + } + /// Die spätere Anbindung und Oberflächentests liefern denselben konkreten Auftrag zurück. + pub fn export_result(&mut self, request: &ExportRequest, status: ExportStatus) -> Result<()> { + let d = self + .dialog + .as_mut() + .ok_or_else(|| anyhow!("Kein Exportdialog offen"))?; + ensure!( + d.request.as_ref() == Some(request), + "Exportauftrag stimmt nicht mit dem Dialog überein" + ); + ensure!( + request.project == crate::export::ProjectStamp::capture(&self.project), + "Projekt wurde seit dem Exportauftrag geändert" + ); + d.export_status = status.clone(); + d.error.clear(); + self.last_export = Some((request.clone(), status)); + Ok(()) + } + fn code_cursor(&self) -> Result<(DocumentId, usize)> { + let Some(Window { + kind: WindowKind::Code(view), + .. + }) = self.active_window() + else { + return Err(anyhow!("Kein Codefenster aktiv")); + }; + let view = self.project.view(*view)?; + Ok((view.document(), view.cursor)) + } + pub fn handle(&mut self, event: Event) { + if let Event::Resize(w, h) = event { + self.size = (w, h); + return; + } + if self.size.0 < 80 || self.size.1 < 25 { + return; + } + if let Event::Key(key) = event { + if key.kind == KeyEventKind::Release { + return; + } + self.key(key); + } else if let Event::Mouse(mouse) = event { + if self.dialog.is_none() && self.menu.is_none() && self.program_focus() { + if let Some(w) = self.active_window() { + let r = self.rect(w); + let inner = Rect::new( + r.x + 1, + r.y + 1, + r.width.saturating_sub(2), + r.height.saturating_sub(2), + ); + if inner.contains((mouse.column, mouse.row).into()) { + self.basic_events.push(Event::Mouse(mouse)); + return; + } + } + } + if mouse.kind == MouseEventKind::Down(MouseButton::Right) { + if self.dialog.is_none() && self.menu.is_none() && self.options.right_help { + self.execute(Command::Topic); + } + return; + } + if mouse.kind == MouseEventKind::Down(MouseButton::Left) { + let hit = self + .hits + .iter() + .rev() + .find(|(r, _)| r.contains((mouse.column, mouse.row).into())) + .map(|(_, hit)| *hit); + if let Some(hit) = hit { + self.click(hit); + } + } else if self.dialog.is_none() && self.menu.is_none() { + if let Some(Window { + kind: WindowKind::Code(v), + .. + }) = self.active_window() + { + let v = *v; + if let Ok(view) = self.project.view_mut(v) { + match mouse.kind { + MouseEventKind::ScrollDown => view.scroll_line += 1, + MouseEventKind::ScrollUp => { + view.scroll_line = view.scroll_line.saturating_sub(1) + } + _ => {} + } + } + } + } + } else if self.program_focus() { + self.basic_events.push(event); + } + } + fn click(&mut self, hit: Hit) { + if self.dialog.is_some() { + match hit { + Hit::DialogField(i) => { + let d = self.dialog.as_mut().unwrap(); + d.focus = i.min(d.fields.len()); + if let Some(field) = d.fields.get_mut(i) { + if !matches!(field.value, FieldValue::Text(_)) { + field.key(KeyEvent::new(K::Right, M::NONE)); + d.request = None; + d.export_status = ExportStatus::Ready; + } + } + } + Hit::DialogSubmit => self.submit_dialog(), + Hit::DialogCancel => self.cancel_dialog(), + _ => {} + } + return; + } + if self.menu.is_some() { + match hit { + Hit::Menu(i) => self.menu = Some((i, 0)), + Hit::MenuItem(i) => { + if let Some((menu, _)) = self.menu { + self.menu = Some((menu, i)); + self.menu_enter(); + } + } + _ => { + self.menu = None; + self.control_menu = false; + } + } + return; + } + match hit { + Hit::Command(c) => self.execute(c), + Hit::Menu(i) => { + self.properties = false; + self.menu = Some((i, 0)); + } + Hit::Window(id) => self.active = id, + Hit::ProjectMember(i) => { + self.selected_member = i; + if let Some(w) = self.windows.iter().find(|w| w.kind == WindowKind::Project) { + self.active = w.id; + } + } + Hit::ProjectView(form) => { + self.last_command = Some(if form { Command::Form } else { Command::Code }); + if let Some(id) = self.project.members().get(self.selected_member).copied() { + if let Err(e) = self.show_document(id, form) { + self.message = e.to_string(); + } + } + } + _ => {} + } + } + fn cancel_dialog(&mut self) { + if let Some(d) = self.dialog.take() { + if let DialogKind::Browse { return_to, .. } = d.kind { + self.dialog = Some(*return_to); + return; + } + if let Some(request) = d.request { + self.last_export = Some((request, ExportStatus::Cancelled)); + } + } + } + fn program_focus(&self) -> bool { + self.execution == Execution::Running + && matches!( + self.active_window().map(|w| w.kind), + Some(WindowKind::Output) + ) + } + fn key(&mut self, key: KeyEvent) { + if key.code == K::F(2) && self.dialog.is_some() { + self.browse(); + return; + } + if let Some(d) = self.dialog.as_mut() { + match key.code { + K::Esc => self.cancel_dialog(), + K::Enter => self.submit_dialog(), + K::Tab if key.modifiers.contains(M::SHIFT) => { + d.focus = (d.focus + d.fields.len()) % (d.fields.len() + 1) + } + K::BackTab | K::Up => d.focus = (d.focus + d.fields.len()) % (d.fields.len() + 1), + K::Tab | K::Down => d.focus = (d.focus + 1) % (d.fields.len() + 1), + _ => { + if let Some(field) = d.fields.get_mut(d.focus) { + if !(matches!(d.kind, DialogKind::Export(_)) && d.focus == 0) { + field.key(key); + d.request = None; + d.export_status = ExportStatus::Ready; + } + } + } + } + return; + } + if self.mode == Mode::Designer + && key.code == K::F(10) + && !key.modifiers.contains(M::CONTROL) + { + self.menu = None; + self.execute(Command::MenuBar); + return; + } + if self.menu.is_some() { + if key.modifiers.contains(M::ALT) { + if let K::Char(c) = key.code { + if let Some(i) = self + .menus() + .iter() + .position(|m| m.mnemonic == c.to_ascii_lowercase()) + { + self.control_menu = false; + self.menu = Some((i, 0)); + return; + } + } + } + match key.code { + K::Esc => { + self.menu = None; + self.control_menu = false; + } + K::Left | K::Right if !self.control_menu => { + let n = self.menus().len(); + let (m, _) = self.menu.unwrap(); + self.menu = Some(( + (m + n + if key.code == K::Right { 1 } else { n - 1 }) % n, + 0, + )); + } + K::Up | K::Down => { + let (m, i) = self.menu.unwrap(); + let n = self.menu_entries(m).len(); + if n > 0 { + self.menu = + Some((m, (i + n + if key.code == K::Down { 1 } else { n - 1 }) % n)); + } + } + K::Enter => self.menu_enter(), + K::Char(c) => { + let (m, _) = self.menu.unwrap(); + let entries = self.menu_entries(m); + if let Some(i) = entries.iter().position(|(label, _)| { + label + .split_once('&') + .and_then(|(_, s)| s.chars().next()) + .is_some_and(|v| v.eq_ignore_ascii_case(&c)) + }) { + self.menu = Some((m, i)); + self.menu_enter(); + } + } + _ => {} + } + return; + } + if let Some((sizing, original)) = self.geometry_action { + match key.code { + K::Esc => { + if let Some(w) = self.windows.iter_mut().find(|w| w.id == self.active) { + w.normal = original; + } + self.geometry_action = None; + } + K::Enter => self.geometry_action = None, + K::Left | K::Right | K::Up | K::Down => { + let area = self.area(); + if let Some(w) = self.windows.iter_mut().find(|w| w.id == self.active) { + let delta = if key.modifiers.contains(M::CONTROL) { + 5 + } else { + 1 + }; + let grow = matches!(key.code, K::Right | K::Down); + let value = match (sizing, key.code) { + (true, K::Left | K::Right) => &mut w.normal.width, + (true, _) => &mut w.normal.height, + (false, K::Left | K::Right) => &mut w.normal.x, + (false, _) => &mut w.normal.y, + }; + *value = if grow { + value.saturating_add(delta) + } else { + value.saturating_sub(delta) + }; + w.normal = clamp(w.normal, area); + } + } + _ => {} + } + return; + } + if key.code == K::F(11) + || matches!( + key.code, + K::Modifier(ModifierKeyCode::LeftAlt | ModifierKeyCode::RightAlt) + ) + { + self.properties = false; + self.menu = Some((0, 0)); + return; + } + if key.modifiers.contains(M::ALT) { + match key.code { + K::Char('-') => { + self.execute(Command::ControlMenu); + return; + } + K::F(4) => { + self.execute(Command::Exit); + return; + } + K::Char(c) => { + if let Some(i) = self + .menus() + .iter() + .position(|m| m.mnemonic == c.to_ascii_lowercase()) + { + self.properties = false; + self.menu = Some((i, 0)); + return; + } + } + _ => {} + } + } + if self.mode == Mode::Designer + && key.code == K::F(10) + && !key.modifiers.contains(M::CONTROL) + { + self.execute(Command::MenuBar); + return; + } + if self.mode == Mode::Designer && key.code == K::F(2) { + self.properties = true; + self.value_focus = true; + self.message = "Properties Value · Bearbeitung folgt in Change 05".into(); + return; + } + if self.program_focus() && key.modifiers.contains(M::CONTROL) && key.code == K::Char('c') { + self.basic_events.push(Event::Key(key)); + return; + } + if let Some(command) = shortcut(key) { + self.execute(command); + return; + } + if self.program_focus() { + self.basic_events.push(Event::Key(key)); + return; + } + if matches!( + self.active_window().map(|w| w.kind), + Some(WindowKind::Project) + ) { + let n = self.project.members().len(); + match key.code { + K::Down if n > 0 => self.selected_member = (self.selected_member + 1) % n, + K::Up if n > 0 => self.selected_member = (self.selected_member + n - 1) % n, + K::Enter if n > 0 => { + let id = self.project.members()[self.selected_member]; + let form = matches!( + self.project.document(id).unwrap().content(), + Content::Form(_) + ); + if let Err(e) = self.show_document(id, form) { + self.message = e.to_string(); + } + } + _ => {} + } + return; + } + if self.mode == Mode::Environment { + if let Err(e) = self.code_key(key) { + self.message = e.to_string(); + } + } + } + fn browse(&mut self) { + let Some(dialog) = self.dialog.take() else { + return; + }; + let index = dialog.focus; + let allowed = match dialog.kind { + DialogKind::OpenProject + | DialogKind::AddFile + | DialogKind::LoadText + | DialogKind::SaveText => index == 0, + DialogKind::Save { .. } => index % 2 == 0, + DialogKind::Export(_) => index == 3, + _ => false, + }; + if !allowed || index >= dialog.fields.len() { + self.dialog = Some(dialog); + return; + } + let path = self.base.join(dialog.fields[index].string()); + let dir = if path.is_dir() { + path + } else { + path.parent().unwrap_or(&self.base).to_path_buf() + }; + match Self::browser(dialog.clone(), index, &dir) { + Ok(d) => self.dialog = Some(d), + Err(e) => { + let mut dialog = dialog; + dialog.error = e.to_string(); + self.dialog = Some(dialog); + } + } + } + fn browser(return_to: Dialog, field: usize, dir: &Path) -> Result { + let dir = dir.canonicalize()?; + let mut entries = std::fs::read_dir(&dir)? + .map(|e| e.map(|e| e.path())) + .collect::>>()?; + entries.sort_by_key(|p| (!p.is_dir(), p.clone())); + if let Some(parent) = dir.parent() { + entries.insert(0, parent.to_path_buf()); + } + let names = entries + .iter() + .map(|p| { + format!( + "{}{}", + p.file_name().unwrap_or_default().to_string_lossy(), + if p.is_dir() { "/" } else { "" } + ) + }) + .collect(); + Ok(Dialog::new( + format!("Dateiwahl: {}", dir.display()), + DialogKind::Browse { + return_to: Box::new(return_to), + field, + entries, + }, + vec![Field::choice("Datei/Verzeichnis", names, 0)], + )) + } + fn code_key(&mut self, key: KeyEvent) -> Result<()> { + let Some(Window { + kind: WindowKind::Code(view), + .. + }) = self.active_window() + else { + 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(()) + } + pub fn menu_entries(&self, index: usize) -> Vec<(String, Option)> { + if self.control_menu { + return vec![ + ("&Restore".into(), Some(Command::Restore)), + ("&Move".into(), Some(Command::MoveWindow)), + ("&Size".into(), Some(Command::SizeWindow)), + ("Mi&nimize".into(), Some(Command::Minimize)), + ("Ma&ximize".into(), Some(Command::Maximize)), + ("&Close".into(), Some(Command::CloseWindow)), + ]; + } + let menus = self.menus(); + let mut entries = menus[index] + .items + .iter() + .map(|i| (i.label.into(), i.command)) + .collect::>(); + if menus[index].title == "Window" { + entries.extend( + self.window_commands() + .into_iter() + .enumerate() + .map(|(i, (label, c))| (format!("&{} {label}", i + 1), Some(c))), + ); + } + entries + } + fn menu_enter(&mut self) { + if let Some((m, i)) = self.menu { + if let Some((_, Some(c))) = self.menu_entries(m).get(i) { + self.execute(*c); + } + } + } +} +pub fn clamp(r: Rect, area: Rect) -> Rect { + let width = r.width.max(12).min(area.width); + let height = r.height.max(4).min(area.height); + Rect::new( + r.x.clamp(area.x, area.right().saturating_sub(width)), + r.y.clamp(area.y, area.bottom().saturating_sub(height)), + width, + height, + ) +} +fn untitled(project: &mut Project) -> Result { + let mut n = 0; + loop { + let name = if n == 0 { + "Untitled".into() + } else { + format!("Untitled{n}") + }; + if project + .loader()? + .resolve(project.directory(), &format!("{name}.bas")) + .is_err() + { + return project.new_module(&name); + } + n += 1; + } +} +pub fn shortcut(key: KeyEvent) -> Option { + use Command::*; + let ctrl = key.modifiers.contains(M::CONTROL); + let shift = key.modifiers.contains(M::SHIFT); + Some(match key.code { + K::F(6) => { + if shift { + PreviousWindow + } else { + NextWindow + } + } + K::F(4) if ctrl => CloseWindow, + K::F(7) if ctrl => MoveWindow, + K::F(8) if ctrl => SizeWindow, + K::F(9) if ctrl => Minimize, + K::F(10) if ctrl => Maximize, + K::F(5) if ctrl => Restore, + K::F(1) if shift => UsingHelp, + K::F(1) => Topic, + K::F(2) => Code, + K::F(3) => FindNext, + K::F(4) => OutputScreen, + K::F(5) if shift => Start, + K::F(5) => Continue, + K::F(7) => RunToCursor, + K::F(8) if shift => HistoryBack, + K::F(8) => Step, + K::F(9) if shift => InstantWatch, + K::F(9) => Breakpoint, + K::F(10) if shift => HistoryForward, + K::F(10) => ProcedureStep, + K::F(12) if shift => Form, + K::F(12) => Events, + K::Char('c') if ctrl => Copy, + K::Char('x') if ctrl => Cut, + K::Char('v') if ctrl => Paste, + K::Char('z') if ctrl => Undo, + K::Char('\\') if ctrl => SelectedText, + K::Delete => Clear, + _ => return None, + }) +} diff --git a/crates/tb-ide/src/commands.rs b/crates/tb-ide/src/commands.rs new file mode 100644 index 0000000..4affbba --- /dev/null +++ b/crates/tb-ide/src/commands.rs @@ -0,0 +1,337 @@ +//! Eine Befehlsliste für Menü, Tastatur und Statusleiste. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Command { + NewProject, + OpenProject, + SaveProject, + NewForm, + NewModule, + AddFile, + RemoveFile, + SaveFile, + SaveAs, + LoadText, + SaveText, + Print, + Shell, + Exit, + Undo, + Cut, + Copy, + Paste, + Clear, + NewSub, + NewFunction, + Events, + Code, + Form, + NextStatement, + OutputScreen, + IncludedFile, + IncludedLines, + MenuBar, + Grid, + Find, + SelectedText, + FindNext, + Replace, + Start, + Restart, + Continue, + CommandLine, + MakeExe, + MakeLibrary, + Startup, + AddWatch, + InstantWatch, + Watchpoint, + DeleteWatch, + DeleteWatches, + Trace, + History, + Breakpoint, + ClearBreakpoints, + BreakErrors, + SetStatement, + RunToCursor, + Step, + ProcedureStep, + HistoryBack, + HistoryForward, + Display, + Paths, + RightMouse, + SaveOptions, + SyntaxChecking, + NewWindow, + Arrange, + Calls, + Debug, + HelpWindow, + Immediate, + Output, + Project, + Palette, + MenuDesign, + Toolbox, + Tool(&'static str), + HelpIndex, + HelpContents, + Keyboard, + Topic, + UsingHelp, + Tutorial, + About, + NextWindow, + PreviousWindow, + CloseWindow, + MoveWindow, + SizeWindow, + Minimize, + Maximize, + Restore, + ControlMenu, + FocusWindow(u64), +} +impl Command { + pub fn feature_phase(self) -> Option { + 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 + | Trace | History | Breakpoint | ClearBreakpoints | BreakErrors | SetStatement + | RunToCursor | Step | ProcedureStep | HistoryBack | HistoryForward => Some(6), + HelpIndex | HelpContents | Keyboard | Topic | UsingHelp | Tutorial => Some(7), + _ => None, + } + } +} +#[derive(Debug, Clone)] +pub struct Item { + /// & markiert das sichtbare Mnemonic, … einen Dialog. + pub label: &'static str, + pub command: Option, +} +impl Item { + pub fn text(&self) -> String { + self.label.replace('&', "") + } + pub fn mnemonic(&self) -> Option { + self.label + .split_once('&')? + .1 + .chars() + .next() + .map(|c| c.to_ascii_lowercase()) + } +} +pub struct Menu { + pub title: &'static str, + pub mnemonic: char, + pub items: Vec, +} +pub fn menus(designer: bool) -> Vec { + use Command::*; + let item = |label, command| Item { + label, + command: Some(command), + }; + let sep = || Item { + label: "────────", + command: None, + }; + let menu = |title, mnemonic, items| Menu { + title, + mnemonic, + items, + }; + let mut result = vec![ + menu( + "File", + 'f', + vec![ + item("&New Project", NewProject), + item("&Open Project…", OpenProject), + item("Save &Project", SaveProject), + sep(), + item("New &Form", NewForm), + item("New &Module…", NewModule), + item("&Add File…", AddFile), + item("&Remove File…", RemoveFile), + item("&Save File", SaveFile), + item("Sa&ve File As…", SaveAs), + sep(), + item("&Load Text…", LoadText), + item("Save &Text…", SaveText), + sep(), + item("Pr&int…", Print), + item("S&hell", Shell), + sep(), + item("E&xit", Exit), + ], + ), + menu( + "Edit", + 'e', + vec![ + item("&Undo", Undo), + sep(), + item("Cu&t", Cut), + item("&Copy", Copy), + item("&Paste", Paste), + item("C&lear", Clear), + sep(), + item("New &Sub…", NewSub), + item("New &Function…", NewFunction), + item("&Event Procedures…", Events), + ], + ), + menu( + "View", + 'v', + vec![ + item("&Code…", Code), + item("&Form…", Form), + sep(), + item("&Next Statement", NextStatement), + item("&Output Screen", OutputScreen), + sep(), + item("&Included File", IncludedFile), + item("Included &Lines", IncludedLines), + ], + ), + ]; + if designer { + result[2] + .items + .extend([sep(), item("&Menu Bar", MenuBar), item("&Grid Lines", Grid)]); + result.push(menu( + "Tools", + 't', + vec![ + item("&Check Box", Tool("CheckBox")), + item("C&ombo Box", Tool("ComboBox")), + item("Command &Button", Tool("CommandButton")), + item("&Dir List", Tool("DirListBox")), + item("D&rive List", Tool("DriveListBox")), + item("&File List", Tool("FileListBox")), + item("Fr&ame", Tool("Frame")), + item("&HScrollBar", Tool("HScrollBar")), + item("&Label", Tool("Label")), + item("L&ist Box", Tool("ListBox")), + item("O&ption Button", Tool("OptionButton")), + item("Pict&ure Box", Tool("PictureBox")), + item("&Text Box", Tool("TextBox")), + item("Ti&mer", Tool("Timer")), + item("&VScrollBar", Tool("VScrollBar")), + ], + )); + } else { + result.extend([ + menu( + "Search", + 's', + vec![ + item("&Find…", Find), + item("&Selected Text", SelectedText), + item("&Repeat Last Find", FindNext), + item("&Change…", Replace), + ], + ), + menu( + "Run", + 'r', + vec![ + item("&Start", Start), + item("&Restart", Restart), + item("&Continue", Continue), + item("Modify COMMAND&$…", CommandLine), + sep(), + item("Make &EXE File…", MakeExe), + item("Make &Library…", MakeLibrary), + sep(), + item("Set Start-&up File…", Startup), + ], + ), + menu( + "Debug", + 'd', + vec![ + item("&Add Watch…", AddWatch), + item("&Instant Watch…", InstantWatch), + item("&Watchpoint…", Watchpoint), + item("&Delete Watch…", DeleteWatch), + item("Delete A&ll Watch", DeleteWatches), + sep(), + item("&Trace On", Trace), + item("&History On", History), + sep(), + item("Toggle &Breakpoint", Breakpoint), + item("&Clear All Breakpoints", ClearBreakpoints), + item("Break on &Errors", BreakErrors), + item("Set &Next Statement", SetStatement), + ], + ), + ]); + } + result.push(menu( + "Options", + 'o', + vec![ + item("&Display…", Display), + item("Set &Paths…", Paths), + item("&Right Mouse…", RightMouse), + item("&Save…", SaveOptions), + item("S&yntax Checking", SyntaxChecking), + ], + )); + result.push(if designer { + menu( + "Window", + 'w', + vec![ + item("&Color Palette", Palette), + item("&Menu Design Window", MenuDesign), + item("&Toolbox", Toolbox), + item("&Help", HelpWindow), + ], + ) + } else { + menu( + "Window", + 'w', + vec![ + item("&New Window", NewWindow), + item("&Arrange All", Arrange), + sep(), + item("&Calls", Calls), + item("&Debug", Debug), + item("&Help", HelpWindow), + item("&Immediate", Immediate), + item("&Output", Output), + item("&Project", Project), + sep(), + ], + ) + }); + result.push(menu( + "Help", + 'h', + vec![ + item("&Index", HelpIndex), + item("&Contents", HelpContents), + sep(), + item("&Keyboard", Keyboard), + sep(), + item("&Topic", Topic), + item("&Using Help", UsingHelp), + item("Tuto&rial", Tutorial), + sep(), + item("&About…", About), + ], + )); + result +} diff --git a/crates/tb-ide/src/documents.rs b/crates/tb-ide/src/documents.rs index 98f0ead..f41b63f 100644 --- a/crates/tb-ide/src/documents.rs +++ b/crates/tb-ide/src/documents.rs @@ -123,6 +123,9 @@ pub struct Project { pub include_paths: Vec, } impl Project { + pub fn directory(&self) -> &Path { + &self.base + } pub fn new(base: &Path) -> Result { Ok(Self { base: identity(base)?, @@ -538,6 +541,25 @@ impl Project { ); Ok(target) } + /// Gemeinsame Zielprüfung für die Exportdialoge; schreibt keine Datei. + pub fn validate_export_target(&self, destination: &Destination) -> Result { + let path = self.target(&destination.path)?; + ensure!( + path.parent().is_some_and(Path::is_dir), + "Zielverzeichnis fehlt" + ); + ensure!( + self.path.as_deref() != Some(&path), + "Projektdatei ist kein Exportziel" + ); + ensure!( + self.documents.values().all(|d| d.source_path != path), + "Geöffnetes Dokument ist kein Exportziel" + ); + self.protect_binary(&path)?; + check_destination(&path, None, destination.overwrite)?; + Ok(path) + } fn protect_binary(&self, path: &Path) -> Result<()> { ensure!( !fs::read(path).is_ok_and(|b| b.starts_with(&[0xfc, 0x08, 1, 0])), @@ -755,7 +777,12 @@ fn check_destination(path: &Path, expected: Option<&[u8]>, overwrite: bool) -> R } /// Ersetzt erst nach erfolgreichem Schreiben und flush/sync; temporäre Datei im selben Verzeichnis. -fn atomic_write(path: &Path, bytes: &[u8], expected: Option<&[u8]>, overwrite: bool) -> Result<()> { +pub(crate) fn atomic_write( + path: &Path, + bytes: &[u8], + expected: Option<&[u8]>, + overwrite: bool, +) -> Result<()> { check_destination(path, expected, overwrite)?; let parent = path .parent() diff --git a/crates/tb-ide/src/export.rs b/crates/tb-ide/src/export.rs new file mode 100644 index 0000000..ab25cd8 --- /dev/null +++ b/crates/tb-ide/src/export.rs @@ -0,0 +1,97 @@ +//! Konkreter Vertrag für die spätere native Erzeugung, ohne Phase-5-Dateiausgabe. +use crate::documents::{Destination, DocumentId, Project}; +use anyhow::{ensure, Result}; +use std::path::PathBuf; +use tb_vm::project_io::{has_extension, Manifest}; + +pub const UNAVAILABLE: &str = "Native Erzeugung folgt in Phase 6"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Artifact { + Executable, + Library, +} +impl Artifact { + pub fn label(self) -> &'static str { + match self { + Self::Executable => "Natives Standalone-Executable", + Self::Library => "Native Systembibliothek", + } + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectStamp { + pub path: Option, + pub manifest: Manifest, + pub revisions: Vec<(DocumentId, u64)>, +} +impl ProjectStamp { + pub fn capture(project: &Project) -> Self { + Self { + path: project.path().map(PathBuf::from), + manifest: project.manifest().clone(), + revisions: project + .documents() + .map(|(id, d)| (id, d.revision())) + .collect(), + } + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportRequest { + pub project: ProjectStamp, + pub artifact: Artifact, + pub system: String, + pub architecture: String, + pub path: PathBuf, + pub overwrite: bool, +} +impl ExportRequest { + pub fn validate( + project: &Project, + artifact: Artifact, + system: &str, + architecture: &str, + destination: &Destination, + ) -> Result { + ensure!( + ["linux", "macos", "windows"].contains(&system), + "Unbekanntes Zielsystem" + ); + ensure!( + ["x86_64", "aarch64"].contains(&architecture), + "Unbekannte Zielarchitektur" + ); + ensure!( + !has_extension(&destination.path, "tbc"), + "TBC ist kein natives Exportartefakt" + ); + let path = project.validate_export_target(destination)?; + Ok(Self { + project: ProjectStamp::capture(project), + artifact, + system: system.into(), + architecture: architecture.into(), + path, + overwrite: destination.overwrite, + }) + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExportStatus { + Ready, + Running, + Success, + Failed(String), + Cancelled, +} +impl ExportStatus { + pub fn text(&self) -> String { + match self { + Self::Ready => "Geprüft".into(), + Self::Running => "Erzeugung läuft".into(), + Self::Success => "Erfolgreich erzeugt".into(), + Self::Failed(e) => format!("Erzeugung fehlgeschlagen: {e}"), + Self::Cancelled => "Erzeugung abgebrochen".into(), + } + } +} diff --git a/crates/tb-ide/src/lib.rs b/crates/tb-ide/src/lib.rs index 58821c9..d17f244 100644 --- a/crates/tb-ide/src/lib.rs +++ b/crates/tb-ide/src/lib.rs @@ -1,2 +1,8 @@ //! Testbare IDE-Zustände; Terminalbedienung wird vom IDE-Rahmen angebunden. +pub mod app; +pub mod commands; pub mod documents; +pub mod export; +pub mod options; +pub mod render; +pub mod terminal; diff --git a/crates/tb-ide/src/main.rs b/crates/tb-ide/src/main.rs index 06bb1ff..47bceb0 100644 --- a/crates/tb-ide/src/main.rs +++ b/crates/tb-ide/src/main.rs @@ -1,11 +1,33 @@ -//! `tb` — die integrierte Entwicklungsumgebung von Terminal Basic (TUI). -//! -//! Nachbildung der klassischen DOS-IDE: Menüleiste, Editor mit -//! Syntaxprüfung pro Zeile, Formular-Designer, Direktfenster, -//! Debugger (Einzelschritt, Breakpoints, Überwachungsausdrücke). -//! Siehe PLAN.md, Phase 5. +use anyhow::{ensure, Result}; +use crossterm::event; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::{ + io::{self, IsTerminal}, + path::PathBuf, +}; +use tb_ide::{app::App, options, terminal::TerminalGuard}; -fn main() -> anyhow::Result<()> { - println!("tb — Terminal Basic IDE (Projektrahmen, noch ohne Funktion)"); +fn main() -> Result<()> { + let args: Vec<_> = std::env::args().skip(1).collect(); + if args.iter().any(|s| s == "--help" || s == "-h") { + println!("tb [Projekt.mak|Modul.bas|Formular.frm]\nTerminal Basic IDE · mindestens 80×25\nF11: Menü · Alt+F4: Beenden"); + return Ok(()); + } + ensure!(args.len() <= 1, "Aufruf: tb [Projektdatei]"); + ensure!( + io::stdin().is_terminal() && io::stdout().is_terminal(), + "tb benötigt ein interaktives Terminal; --help zeigt den Aufruf" + ); + let base = std::env::current_dir()?; + let mut app = App::new(&base, options::config_path()?, crossterm::terminal::size()?)?; + if let Some(path) = args.first() { + app.load_initial_project(PathBuf::from(path))?; + } + let _guard = TerminalGuard::enter(io::stdout())?; + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + while !app.quit { + terminal.draw(|f| app.render(f))?; + app.handle(event::read()?); + } Ok(()) } diff --git a/crates/tb-ide/src/options.rs b/crates/tb-ide/src/options.rs new file mode 100644 index 0000000..da2962f --- /dev/null +++ b/crates/tb-ide/src/options.rs @@ -0,0 +1,187 @@ +use anyhow::{anyhow, ensure, Context, Result}; +use std::{ + fs, + path::{Path, PathBuf}, +}; +use unicode_width::UnicodeWidthChar; + +pub const ELEMENTS: [&str; 7] = [ + "Menü", + "Desktop", + "Code", + "Aktiver Titel", + "Rahmen", + "Dialog/Projekt", + "Status", +]; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Options { + pub colors: [(u8, u8); 7], + pub desktop: char, + pub tab_width: usize, + pub include_paths: Vec, + pub source_dir: PathBuf, + pub output_dir: PathBuf, + pub right_help: bool, + pub syntax_checking: bool, +} +impl Default for Options { + fn default() -> Self { + Self { + colors: [(0, 7), (7, 0), (7, 1), (15, 5), (0, 7), (0, 7), (0, 3)], + desktop: '▒', + tab_width: 8, + include_paths: vec![], + source_dir: PathBuf::new(), + output_dir: PathBuf::new(), + right_help: true, + syntax_checking: true, + } + } +} +pub fn config_path() -> Result { + let base = if cfg!(windows) { + std::env::var_os("APPDATA").map(PathBuf::from) + } else if let Some(path) = std::env::var_os("XDG_CONFIG_HOME") { + Some(PathBuf::from(path)) + } else { + std::env::var_os("HOME").map(|p| { + PathBuf::from(p).join(if cfg!(target_os = "macos") { + "Library/Application Support" + } else { + ".config" + }) + }) + } + .ok_or_else(|| anyhow!("Benutzer-Konfigurationsverzeichnis fehlt"))?; + ensure!( + base.is_absolute(), + "Konfigurationsverzeichnis muss absolut sein" + ); + Ok(base.join("TerminalBasic/options.ini")) +} +impl Options { + pub fn validate(&self) -> Result<()> { + ensure!( + self.colors.iter().all(|(fg, bg)| *fg < 16 && *bg < 16), + "Farben müssen zwischen 0 und 15 liegen" + ); + ensure!( + (1..=32).contains(&self.tab_width), + "Tabweite muss zwischen 1 und 32 liegen" + ); + ensure!( + self.desktop.width() == Some(1) && !self.desktop.is_control(), + "Desktopzeichen muss genau eine Terminalzelle belegen" + ); + for path in self + .include_paths + .iter() + .chain([&self.source_dir, &self.output_dir]) + { + if path.as_os_str().is_empty() { + continue; + } + ensure!( + path.is_absolute() + && path + .to_str() + .is_some_and(|s| !s.contains(['\n', '\r', '\0', '|'])), + "Optionspfade müssen absolute Verzeichnispfade sein (ohne | oder Zeilenumbruch)" + ); + } + Ok(()) + } + pub fn load(path: &Path) -> (Self, Vec, Option>) { + let bytes = match fs::read(path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return (Self::default(), vec![], None) + } + Err(e) => { + return ( + Self::default(), + vec![format!("{}: {e}", path.display())], + None, + ) + } + }; + let Ok(text) = std::str::from_utf8(&bytes) else { + return ( + Self::default(), + vec!["Optionsdatei ist nicht UTF-8; Defaults aktiv".into()], + Some(bytes), + ); + }; + let mut value = Self::default(); + let mut errors = vec![]; + if !text.lines().any(|l| l == "version=1") { + return ( + value, + vec!["Unbekannte Optionsversion; Defaults aktiv".into()], + Some(bytes), + ); + } + for line in text + .lines() + .filter(|l| !l.is_empty() && !l.starts_with('#')) + { + let Some((key, s)) = line.split_once('=') else { + errors.push(format!("Ungültige Optionszeile: {line}")); + continue; + }; + let mut candidate = value.clone(); + let result = (|| -> Result<()> { + match key { + "version" => ensure!(s == "1", "Unbekannte Version"), + "desktop" => { + let mut chars = s.chars(); + candidate.desktop = chars + .next() + .ok_or_else(|| anyhow!("Desktopzeichen fehlt"))?; + ensure!(chars.next().is_none(), "Ein Desktopzeichen erforderlich"); + } + "tab_width" => candidate.tab_width = s.parse()?, + "right_help" => candidate.right_help = s.parse()?, + "syntax_checking" => candidate.syntax_checking = s.parse()?, + "source_dir" => candidate.source_dir = s.into(), + "output_dir" => candidate.output_dir = s.into(), + "include_paths" => { + candidate.include_paths = s + .split('|') + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect() + } + _ if key.starts_with("color.") => { + let i: usize = key[6..].parse()?; + ensure!(i < 7, "Unbekanntes Farbelement"); + let (a, b) = s.split_once(',').ok_or_else(|| anyhow!("Farbpaar fehlt"))?; + candidate.colors[i] = (a.parse()?, b.parse()?); + } + _ => { + errors.push(format!("Unbekannte Option {key} ignoriert")); + return Ok(()); + } + } + candidate.validate() + })(); + match result { + Ok(()) => value = candidate, + Err(e) => errors.push(format!("Option {key}: {e}; Vorgabe beibehalten")), + } + } + (value, errors, Some(bytes)) + } + pub fn save(&self, path: &Path, expected: Option<&[u8]>) -> Result> { + self.validate()?; + let mut text=format!("version=1\ndesktop={}\ntab_width={}\nright_help={}\nsyntax_checking={}\nsource_dir={}\noutput_dir={}\ninclude_paths={}\n",self.desktop,self.tab_width,self.right_help,self.syntax_checking,self.source_dir.display(),self.output_dir.display(),self.include_paths.iter().map(|p|p.to_str().unwrap()).collect::>().join("|")); + for (i, (fg, bg)) in self.colors.iter().enumerate() { + text.push_str(&format!("color.{i}={fg},{bg}\n")); + } + fs::create_dir_all(path.parent().ok_or_else(|| anyhow!("Optionspfad fehlt"))?) + .context("Optionsverzeichnis erstellen")?; + crate::documents::atomic_write(path, text.as_bytes(), expected, false)?; + Ok(text.into_bytes()) + } +} diff --git a/crates/tb-ide/src/render.rs b/crates/tb-ide/src/render.rs new file mode 100644 index 0000000..8560129 --- /dev/null +++ b/crates/tb-ide/src/render.rs @@ -0,0 +1,569 @@ +use crate::{ + app::{App, DialogKind, FieldValue, Hit, Mode, WindowKind, WindowState}, + commands::Command, +}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, + Frame, +}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +pub fn dos(n: u8) -> Color { + [ + Color::Black, + Color::Blue, + Color::Green, + Color::Cyan, + Color::Red, + Color::Magenta, + Color::Yellow, + Color::Gray, + Color::DarkGray, + Color::LightBlue, + Color::LightGreen, + Color::LightCyan, + Color::LightRed, + Color::LightMagenta, + Color::LightYellow, + Color::White, + ][n.min(15) as usize] +} +fn style(app: &App, element: usize) -> Style { + let (fg, bg) = app.options.colors[element]; + Style::default().fg(dos(fg)).bg(dos(bg)) +} +fn put(f: &mut Frame, area: Rect, text: impl Into, style: Style) { + f.render_widget(Paragraph::new(text.into()).style(style), area); +} +pub fn expanded(text: &str, tab: usize) -> String { + let mut out = String::new(); + let mut column = 0; + for c in text.chars() { + if c == '\t' { + let n = tab - column % tab; + out.extend(std::iter::repeat_n(' ', n)); + column += n; + } else { + out.push(c); + column += c.width().unwrap_or(0); + } + } + out +} +pub const STATUS: [(&str, Command); 5] = [ + ("", Command::UsingHelp), + ("", Command::NextWindow), + ("", Command::Code), + ("", Command::Continue), + ("", Command::Step), +]; + +impl App { + pub fn render(&mut self, f: &mut Frame) { + let area = f.area(); + self.size = (area.width, area.height); + self.hits.clear(); + if area.width < 80 || area.height < 25 { + f.render_widget(Clear, area); + put( + f, + area, + "Terminal Basic benötigt mindestens 80×25 Zellen.", + Style::default(), + ); + return; + } + let fill = self.options.desktop.to_string().repeat(area.width as usize); + for row in 0..area.height { + put( + f, + Rect::new(0, row, area.width, 1), + fill.clone(), + style(self, 1), + ); + } + let mut windows = self.windows.clone(); + windows.sort_by_key(|w| w.id == self.active); + for w in windows { + let rect = self.rect(&w); + if rect.width < 2 || rect.height < 2 { + continue; + } + let active = w.id == self.active; + let content_style = style( + self, + if matches!(w.kind, WindowKind::Code(_)) { + 2 + } else { + 5 + }, + ); + f.render_widget( + Block::default() + .borders(Borders::ALL) + .style(content_style) + .border_style(style(self, 4)), + rect, + ); + self.hits.push((rect, Hit::Window(w.id))); + let title_style = style(self, if active { 3 } else { 4 }); + put( + f, + Rect::new(rect.x, rect.y, rect.width, 1), + format!("[≡] {}", self.title(&w)), + title_style, + ); + if active { + self.hits.push(( + Rect::new(rect.x, rect.y, 3, 1), + Hit::Command(Command::ControlMenu), + )); + } + if rect.width > 10 { + let x = rect.right() - 6; + put(f, Rect::new(x, rect.y, 6, 1), "[_][↑]", title_style); + if active { + self.hits + .push((Rect::new(x, rect.y, 3, 1), Hit::Command(Command::Minimize))); + self.hits.push(( + Rect::new(x + 3, rect.y, 3, 1), + Hit::Command(Command::Maximize), + )); + } + } + if w.state == WindowState::Minimized { + continue; + } + let inner = Rect::new(rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2); + match w.kind { + WindowKind::Code(view) => { + let v = self.project.view(view).unwrap(); + let doc = self.project.document(v.document()).unwrap(); + if self.mode == Mode::Designer + && active + && matches!(doc.content(), tb_vm::project_io::Content::Form(_)) + { + put( + f, + inner, + "Formular · Designerwerkzeuge folgen in Change 05", + content_style, + ); + } else { + let lines: Vec<_> = doc + .code() + .split('\n') + .map(|line| expanded(line, self.options.tab_width)) + .collect(); + let count = lines.len(); + let longest = lines.iter().map(|s| s.width()).max().unwrap_or(0); + let text = lines + .into_iter() + .skip(v.scroll_line) + .map(|s| Line::raw(s.chars().skip(v.scroll_column).collect::())) + .collect::>(); + f.render_widget(Paragraph::new(text).style(content_style), inner); + if count > inner.height as usize { + f.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .style(style(self, 4)), + rect, + &mut ScrollbarState::new(count) + .position(v.scroll_line) + .viewport_content_length(inner.height as usize), + ); + } + if longest > inner.width as usize { + f.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::HorizontalBottom) + .style(style(self, 4)), + rect, + &mut ScrollbarState::new(longest) + .position(v.scroll_column) + .viewport_content_length(inner.width as usize), + ); + } + if active && 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( + before.rsplit('\n').next().unwrap_or(""), + self.options.tab_width, + ) + .width(); + if row >= v.scroll_line + && row - v.scroll_line < inner.height as usize + && col >= v.scroll_column + && col - v.scroll_column < inner.width as usize + { + f.set_cursor_position(( + inner.x + (col - v.scroll_column) as u16, + inner.y + (row - v.scroll_line) as u16, + )); + } + } + } + } + WindowKind::Project => { + let button_width = 9.min(inner.width / 2); + for (i, (text, c)) in [("Form", Command::Form), ("Code", Command::Code)] + .into_iter() + .enumerate() + { + let r = Rect::new( + inner.x + i as u16 * button_width, + inner.y, + button_width, + 3.min(inner.height), + ); + f.render_widget( + Paragraph::new(text) + .block( + Block::bordered() + .border_type(ratatui::widgets::BorderType::Double) + .border_style(if active { + Style::default().fg(Color::White) + } else { + style(self, 4) + }), + ) + .style(content_style), + r, + ); + self.hits.push((r, Hit::ProjectView(c == Command::Form))); + } + let members = self.project.members(); + let visible = inner.height.saturating_sub(3) as usize; + let start = self + .selected_member + .saturating_sub(visible.saturating_sub(1)); + for (row, id) in members.iter().enumerate().skip(start).take(visible) { + let r = + Rect::new(inner.x, inner.y + 3 + (row - start) as u16, inner.width, 1); + let doc = self.project.document(*id).unwrap(); + let text = format!( + "{}{}", + if self.project.startup() == Some(*id) { + "▶ " + } else { + " " + }, + doc.source_path() + .file_name() + .unwrap_or_default() + .to_string_lossy() + ); + put( + f, + r, + text, + if row == self.selected_member { + Style::default().fg(Color::White).bg(Color::Black) + } else { + content_style + }, + ); + self.hits.push((r, Hit::ProjectMember(row))); + } + } + kind => put( + f, + inner, + match kind { + WindowKind::Output => "Output · Ausführung folgt in Change 04", + WindowKind::Help => "Help · Inhalte folgen in Change 07", + _ => "Debugger-Inhalte folgen in Change 06", + }, + content_style, + ), + } + } + if self.mode == Mode::Environment { + let row = area.height - 1; + put( + f, + Rect::new(0, row, area.width, 1), + " ".repeat(area.width as usize), + style(self, 6), + ); + let mut x = 0; + for (label, c) in STATUS { + let width = label.len() as u16; + if x + width > area.width.saturating_sub(11) { + break; + } + put(f, Rect::new(x, row, width, 1), label, style(self, 6)); + self.hits + .push((Rect::new(x, row, width, 1), Hit::Command(c))); + x += width + 1; + } + let pos = self + .active_window() + .and_then(|w| { + if let WindowKind::Code(v) = w.kind { + self.project.view(v).ok() + } else { + None + } + }) + .map(|v| { + let code = self.project.document(v.document()).unwrap().code(); + let before = &code[..v.cursor.min(code.len())]; + format!( + "{:05}:{:03}", + before.bytes().filter(|c| *c == b'\n').count() + 1, + expanded( + before.rsplit('\n').next().unwrap_or(""), + self.options.tab_width + ) + .width() + + 1 + ) + }) + .unwrap_or_else(|| "00001:001".into()); + put(f, Rect::new(area.width - 9, row, 9, 1), pos, style(self, 6)); + put( + f, + Rect::new(0, row - 1, area.width, 1), + self.message.clone(), + style(self, 1), + ); + } + if self.mode == Mode::Designer && self.properties { + put( + f, + Rect::new(0, 0, area.width, 1), + format!( + "Property: [Caption]↓ Value: […]↓ │ Spalte,Zeile │ BxH{}", + if self.value_focus { + " · Value aktiv" + } else { + "" + } + ), + style(self, 0), + ); + self.hits.push(( + Rect::new(0, 0, area.width, 1), + Hit::Command(Command::MenuBar), + )); + } else { + put( + f, + Rect::new(0, 0, area.width, 1), + " ".repeat(area.width as usize), + style(self, 0), + ); + let menus = self.menus(); + let mut x = 0; + for (i, m) in menus.iter().enumerate() { + if m.title == "Help" { + x = area.width - 6; + } + let r = Rect::new(x, 0, m.title.len() as u16 + 2, 1); + let spans = vec![ + Span::raw(" "), + Span::styled( + &m.title[..1], + style(self, 0).add_modifier(Modifier::UNDERLINED), + ), + Span::raw(format!("{} ", &m.title[1..])), + ]; + f.render_widget( + Paragraph::new(Line::from(spans)).style( + if self.menu.is_some_and(|(n, _)| n == i) { + Style::default().fg(Color::White).bg(Color::Black) + } else { + style(self, 0) + }, + ), + r, + ); + self.hits.push((r, Hit::Menu(i))); + x += r.width; + } + } + if let Some((menu, selected)) = self.menu { + let entries = self.menu_entries(menu); + let width = (entries.iter().map(|(s, _)| s.width()).max().unwrap_or(12) as u16 + 4) + .min(area.width); + let menus = self.menus(); + let x = if self.control_menu { + self.active_window().map(|w| self.rect(w).x).unwrap_or(0) + } else if menus[menu].title == "Help" { + area.width - width + } else { + menus + .iter() + .take(menu) + .map(|m| m.title.len() as u16 + 2) + .sum::() + .min(area.width - width) + }; + let visible = (area.height - 4) as usize; + let start = selected.saturating_sub(visible - 1); + let rect = Rect::new(x, 1, width, (entries.len().min(visible) + 2) as u16); + f.render_widget(Clear, rect); + f.render_widget(Block::bordered().style(style(self, 0)), rect); + for (i, (text, c)) in entries.iter().enumerate().skip(start).take(visible) { + let disabled = c.and_then(|c| self.availability(c)); + let st = if disabled.is_some() { + Style::default().fg(Color::DarkGray).bg(Color::Gray) + } else if i == selected { + Style::default().fg(Color::White).bg(Color::Black) + } else { + style(self, 0) + }; + let r = Rect::new(x + 1, 2 + (i - start) as u16, width - 2, 1); + let label = if *c == Some(Command::SyntaxChecking) && self.options.syntax_checking { + format!("• {text}") + } else { + text.clone() + }; + let mut spans = Vec::new(); + let mut underline = false; + for c in label.chars() { + if c == '&' { + underline = true; + continue; + } + spans.push(Span::styled( + c.to_string(), + if underline { + st.add_modifier(Modifier::UNDERLINED) + } else { + st + }, + )); + underline = false; + } + f.render_widget(Paragraph::new(Line::from(spans)).style(st), r); + self.hits.push((r, Hit::MenuItem(i))); + } + if let Some((_, Some(c))) = entries.get(selected) { + if let Some(reason) = self.availability(*c) { + put( + f, + Rect::new(0, area.height - 2, area.width, 1), + reason, + style(self, 0), + ); + } + } + } + if let Some(d) = &self.dialog { + let width = 76.min(area.width - 2); + let height = (d.fields.len() as u16 + 8).min(area.height - 2); + let rect = Rect::new( + (area.width - width) / 2, + (area.height - height) / 2, + width, + height, + ); + let st = style(self, 5); + f.render_widget(Clear, rect); + f.render_widget(Block::bordered().title(d.title.clone()).style(st), rect); + let visible = height.saturating_sub(7) as usize; + let start = d + .focus + .min(d.fields.len().saturating_sub(1)) + .saturating_sub(visible.saturating_sub(1)); + for (i, field) in d.fields.iter().enumerate().skip(start).take(visible) { + let r = Rect::new(rect.x + 2, rect.y + 1 + (i - start) as u16, width - 4, 1); + let value = match &field.value { + FieldValue::Choice { .. } => format!("◄ {} ►", field.string()), + FieldValue::Toggle(v) => { + if *v { + "[x]".into() + } else { + "[ ]".into() + } + } + _ => field.string(), + }; + let label = format!("{}: {}", field.label, value); + let selected = d.focus == i; + let field_style = if selected { + Style::default().fg(Color::White).bg(Color::Black) + } else { + st + }; + let caret = if let FieldValue::Text(text) = &field.value { + field.label.width() + 2 + text[..field.cursor].width() + } else { + 0 + }; + let scroll = if selected { + caret.saturating_sub(r.width.saturating_sub(1) as usize) + } else { + 0 + }; + f.render_widget( + Paragraph::new(label) + .style(field_style) + .scroll((0, scroll.min(u16::MAX as usize) as u16)), + r, + ); + if selected + && matches!(field.value, FieldValue::Text(_)) + && !(matches!(d.kind, DialogKind::Export(_)) && i == 0) + { + f.set_cursor_position((r.x + (caret - scroll) as u16, r.y)); + } + self.hits.push((r, Hit::DialogField(i))); + } + let export = matches!(d.kind, DialogKind::Export(_)); + let submit = Rect::new(rect.x + 2, rect.bottom() - 3, 16, 1); + let cancel = Rect::new(rect.x + 20, rect.bottom() - 3, 14, 1); + put( + f, + submit, + if export { "[Prüfen]" } else { "[OK / Enter]" }, + if d.focus == d.fields.len() { + Style::default().fg(Color::White).bg(Color::Black) + } else { + st + }, + ); + put(f, cancel, "[Abbrechen]", st); + self.hits.push((submit, Hit::DialogSubmit)); + self.hits.push((cancel, Hit::DialogCancel)); + if export { + put( + f, + Rect::new(rect.x + 36, rect.bottom() - 3, width - 38, 1), + "[Erzeugen: Phase 6]", + Style::default().fg(Color::DarkGray).bg(Color::Gray), + ); + } + let mut status = d.error.clone(); + if export { + if let Some(request) = &d.request { + status = format!( + "{}: {} · {}", + d.export_status.text(), + request.path.display(), + d.error + ); + } else if status.is_empty() { + status = crate::export::UNAVAILABLE.into(); + } + } + f.render_widget( + Paragraph::new(status) + .wrap(ratatui::widgets::Wrap { trim: false }) + .style(st), + Rect::new(rect.x + 2, rect.bottom() - 6, width - 4, 3), + ); + put( + f, + Rect::new(rect.x + 2, rect.bottom() - 2, width - 4, 1), + "Tab: Feld · ◄/►: Auswahl · F2: Dateiwahl · Esc: Abbrechen", + st, + ); + } + } +} diff --git a/crates/tb-ide/src/terminal.rs b/crates/tb-ide/src/terminal.rs new file mode 100644 index 0000000..2837e0d --- /dev/null +++ b/crates/tb-ide/src/terminal.rs @@ -0,0 +1,102 @@ +use crossterm::{ + cursor::{Hide, Show}, + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + style::ResetColor, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use std::io::{self, Write}; + +/// Ein Besitzer für Raw Mode und Terminalzustand, auch bei teilweiser Initialisierung. +pub struct TerminalGuard { + writer: W, + raw: fn(bool) -> io::Result<()>, +} +impl TerminalGuard { + pub fn enter(writer: W) -> io::Result { + Self::with_raw(writer, |on| { + if on { + enable_raw_mode() + } else { + disable_raw_mode() + } + }) + } + fn with_raw(writer: W, raw: fn(bool) -> io::Result<()>) -> io::Result { + let mut guard = Self { writer, raw }; + (guard.raw)(true)?; + execute!(guard.writer, EnterAlternateScreen, EnableMouseCapture, Hide)?; + Ok(guard) + } +} +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = (self.raw)(false); + let _ = execute!( + self.writer, + ResetColor, + Show, + DisableMouseCapture, + LeaveAlternateScreen + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::RefCell, + rc::Rc, + sync::atomic::{AtomicI32, Ordering}, + }; + static RAW: AtomicI32 = AtomicI32::new(0); + #[derive(Clone)] + struct Output(Rc>>); + impl Write for Output { + fn write(&mut self, b: &[u8]) -> io::Result { + self.0.borrow_mut().extend_from_slice(b); + Ok(b.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + fn raw(on: bool) -> io::Result<()> { + RAW.store(i32::from(on), Ordering::SeqCst); + Ok(()) + } + #[test] + fn terminal_cleanup_on_normal_error_and_partial_initialization() { + for fail in [false, true] { + let out = Output(Rc::default()); + let copy = out.clone(); + let result = (|| -> io::Result<()> { + let _guard = TerminalGuard::with_raw(out, raw)?; + if fail { + return Err(io::Error::other("Renderfehler")); + } + Ok(()) + })(); + assert_eq!(result.is_err(), fail); + assert_eq!(RAW.load(Ordering::SeqCst), 0); + let s = String::from_utf8(copy.0.borrow().clone()).unwrap(); + assert!(s.contains("?1049h") && s.contains("?1049l") && s.contains("?25h")); + } + let out = Output(Rc::default()); + let copy = out.clone(); + assert!(TerminalGuard::with_raw(out, |on| { + raw(on)?; + if on { + Err(io::Error::other("Raw-Fehler")) + } else { + Ok(()) + } + }) + .is_err()); + assert_eq!(RAW.load(Ordering::SeqCst), 0); + assert!(String::from_utf8(copy.0.borrow().clone()) + .unwrap() + .contains("?25h")); + } +} diff --git a/crates/tb-ide/tests/app.rs b/crates/tb-ide/tests/app.rs new file mode 100644 index 0000000..9dde3df --- /dev/null +++ b/crates/tb-ide/tests/app.rs @@ -0,0 +1,693 @@ +use crossterm::event::{ + Event, KeyCode as K, KeyEvent, KeyModifiers as M, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::{backend::TestBackend, style::Color, Terminal}; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, +}; +use tb_ide::{ + app::{App, DialogKind, Execution, Mode, WindowKind, WindowState}, + commands::{self, Command}, + export::{ExportStatus, ProjectStamp}, + options::Options, +}; + +struct Temp(PathBuf); +impl Temp { + fn new() -> Self { + static N: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "tb-app-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path.canonicalize().unwrap()) + } + fn app(&self) -> App { + App::new(&self.0, self.0.join("config/options.ini"), (100, 30)).unwrap() + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn key(app: &mut App, k: K, m: M) { + app.handle(Event::Key(KeyEvent::new(k, m))); +} +fn plain(app: &mut App, k: K) { + key(app, k, M::NONE); +} +fn type_text(app: &mut App, text: &str) { + for c in text.chars() { + plain(app, K::Char(c)); + } +} +fn field(app: &mut App, index: usize, text: &str) { + while app.dialog.as_ref().unwrap().focus != index { + plain(app, K::Tab); + } + key(app, K::Char('a'), M::CONTROL); + plain(app, K::Delete); + type_text(app, text); +} +fn menu(app: &mut App, command: Command) { + let menus = app.menus(); + let (m, i) = menus + .iter() + .enumerate() + .find_map(|(m, menu)| { + menu.items + .iter() + .position(|i| i.command == Some(command)) + .map(|i| (m, i)) + }) + .unwrap(); + key(app, K::Char(menus[m].mnemonic), M::ALT); + for _ in 0..i { + plain(app, K::Down); + } + plain(app, K::Enter); + assert_eq!(app.last_command, Some(command)); +} +fn draw(app: &mut App) -> (String, ratatui::buffer::Buffer) { + let mut term = Terminal::new(TestBackend::new(app.size.0, app.size.1)).unwrap(); + term.draw(|f| app.render(f)).unwrap(); + let b = term.backend().buffer().clone(); + let text = b + .content + .chunks(app.size.0 as usize) + .map(|row| row.iter().map(|c| c.symbol()).collect::()) + .collect::>() + .join("\n"); + (text, b) +} +fn click(app: &mut App, x: u16, y: u16) { + app.handle(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: x, + row: y, + modifiers: M::NONE, + })); +} + +#[test] +fn start_snapshot_and_reference_menus_have_all_commands() { + let t = Temp::new(); + let mut app = t.app(); + let (text, b) = draw(&mut app); + assert!( + text.contains("[1] Untitled") + && text.contains("Project: Untitled") + && text.contains("Copyright") + && text.contains("00001:001") + ); + assert_eq!(b[(4, 1)].bg, Color::Magenta); + assert_eq!(b[(4, 1)].fg, Color::White); + assert_eq!(b[(4, 2)].bg, Color::Blue); + assert_eq!(b[(1, 29)].bg, Color::Cyan); + assert_eq!(b[(1, 0)].bg, Color::Gray); + plain(&mut app, K::Char('x')); + assert_eq!( + app.project + .document(app.active_document().unwrap()) + .unwrap() + .code(), + "x" + ); + assert_eq!( + commands::menus(false) + .iter() + .map(|m| m.title) + .collect::>(), + ["File", "Edit", "View", "Search", "Run", "Debug", "Options", "Window", "Help"] + ); + assert_eq!( + commands::menus(true) + .iter() + .map(|m| m.title) + .collect::>(), + ["File", "Edit", "View", "Tools", "Options", "Window", "Help"] + ); + let reference = include_str!("../../../docs/ide-referenz.md"); + for designer in [false, true] { + for m in commands::menus(designer) { + let mut mnemonics = std::collections::BTreeSet::new(); + for item in m.items.iter().filter(|i| i.command.is_some()) { + let name = item.text(); + assert!( + reference.contains(name.trim_end_matches('…')), + "Nicht in Referenz: {name}" + ); + assert!(item.mnemonic().is_some()); + assert!( + mnemonics.insert(item.mnemonic().unwrap()), + "Doppeltes Mnemonic in {}: {}", + m.title, + item.label + ); + } + } + } + assert_eq!( + commands::menus(false)[0] + .items + .iter() + .filter_map(|i| i.command) + .collect::>(), + [ + Command::NewProject, + Command::OpenProject, + Command::SaveProject, + Command::NewForm, + Command::NewModule, + Command::AddFile, + Command::RemoveFile, + Command::SaveFile, + Command::SaveAs, + Command::LoadText, + Command::SaveText, + Command::Print, + Command::Shell, + Command::Exit + ] + ); +} + +#[test] +fn modal_focus_and_dirty_project_cancel_preserve_document() { + let t = Temp::new(); + let mut app = t.app(); + type_text(&mut app, "PRINT 7"); + let id = app.active_document().unwrap(); + let active = app.active; + menu(&mut app, Command::NewModule); + type_text(&mut app, "Other"); + plain(&mut app, K::F(6)); + key(&mut app, K::Char('c'), M::CONTROL); + plain(&mut app, K::Esc); + assert_eq!(app.active, active); + assert_eq!(app.project.document(id).unwrap().code(), "PRINT 7"); + assert!(app.basic_events.is_empty()); + key(&mut app, K::F(4), M::ALT); + assert!(matches!( + app.dialog.as_ref().unwrap().kind, + DialogKind::Dirty(_) + )); + plain(&mut app, K::Enter); + assert!(!app.quit); + menu(&mut app, Command::NewProject); + plain(&mut app, K::Right); + plain(&mut app, K::Enter); + assert!(matches!( + app.dialog.as_ref().unwrap().kind, + DialogKind::Save { .. } + )); + plain(&mut app, K::Esc); + assert!(app.project.document(id).unwrap().is_dirty()); + assert!(!app.quit); + menu(&mut app, Command::SaveProject); + field(&mut app, 0, &t.0.join("project.mak").display().to_string()); + plain(&mut app, K::Enter); + assert!(app.dialog.is_none(), "{:?}", app.dialog); + assert!(!app.project.is_dirty()); + menu(&mut app, Command::Exit); + assert!(app.quit); +} + +#[test] +fn dispatcher_keeps_function_keys_and_copy_out_of_basic_input() { + let t = Temp::new(); + 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")); + plain(&mut app, K::F(11)); + assert!(app.menu.is_some()); + plain(&mut app, K::Esc); + menu(&mut app, Command::NewForm); + let form = app.active_document(); + assert_eq!(app.mode, Mode::Designer); + plain(&mut app, K::F(2)); + assert!(app.value_focus); + plain(&mut app, K::F(10)); + assert!(!app.properties && app.menu.is_some()); + plain(&mut app, K::F(10)); + assert!(app.properties && app.menu.is_none()); + plain(&mut app, K::F(12)); + assert_eq!(app.last_command, Some(Command::Events)); + assert_eq!(app.active_document(), form); + assert!(app.basic_events.is_empty()); + plain(&mut app, K::F(11)); + plain(&mut app, K::Esc); + menu(&mut app, Command::Code); + plain(&mut app, K::Enter); + assert_eq!(app.mode, Mode::Environment); + app.execution = Execution::Paused; + plain(&mut app, K::F(10)); + assert_eq!(app.last_command, Some(Command::ProcedureStep)); + assert!(app.message.contains("06")); + menu(&mut app, Command::Output); + app.execution = Execution::Running; + plain(&mut app, K::Char('a')); + assert_eq!(app.basic_events.len(), 1); + plain(&mut app, K::F(11)); + assert_eq!(app.basic_events.len(), 1); +} + +#[test] +fn windows_restore_geometry_and_resize_without_document_loss() { + let t = Temp::new(); + let mut app = t.app(); + type_text(&mut app, "abc"); + let doc = app.active_document().unwrap(); + let first = app.active; + menu(&mut app, Command::NewWindow); + assert_eq!(app.active_document(), Some(doc)); + assert_eq!(app.windows.len(), 3); + key(&mut app, K::F(8), M::CONTROL); + plain(&mut app, K::Left); + plain(&mut app, K::Up); + plain(&mut app, K::Enter); + key(&mut app, K::F(7), M::CONTROL); + plain(&mut app, K::Right); + plain(&mut app, K::Down); + plain(&mut app, K::Enter); + let normal = app.active_window().unwrap().normal; + key(&mut app, K::F(9), M::CONTROL); + assert_eq!(app.active_window().unwrap().state, WindowState::Minimized); + key(&mut app, K::F(10), M::CONTROL); + assert_eq!(app.active_window().unwrap().state, WindowState::Maximized); + key(&mut app, K::F(5), M::CONTROL); + assert_eq!(app.active_window().unwrap().normal, normal); + let active = app.active; + app.handle(Event::Resize(40, 12)); + let (small, _) = draw(&mut app); + assert!(small.contains("mindestens")); + assert!(!small.contains("Untitled")); + plain(&mut app, K::Char('z')); + app.handle(Event::Resize(80, 25)); + draw(&mut app); + assert_eq!(app.active, active); + assert_eq!(app.project.document(doc).unwrap().code(), "abc"); + for w in &app.windows { + let r = app.rect(w); + assert!(r.right() <= 80 && r.bottom() <= 24); + } + key(&mut app, K::Char('-'), M::ALT); + assert!(app.control_menu); + plain(&mut app, K::Esc); + key(&mut app, K::F(4), M::CONTROL); + assert_eq!(app.windows.len(), 2); + assert!(app.project.document(doc).is_ok()); + assert_eq!(app.active, first); + plain(&mut app, K::F(6)); + assert_ne!(app.active, first); + key(&mut app, K::F(6), M::SHIFT); + assert_eq!(app.active, first); + menu(&mut app, Command::Arrange); + assert_eq!(app.windows[0].normal.right(), app.windows[1].normal.x); +} + +#[test] +fn status_and_project_buttons_use_the_keyboard_command_path() { + let t = Temp::new(); + let mut app = t.app(); + draw(&mut app); + let before = app.active; + click(&mut app, 17, 29); + assert_eq!(app.last_command, Some(Command::NextWindow)); + assert_ne!(app.active, before); + let project = app + .windows + .iter() + .find(|w| w.kind == WindowKind::Project) + .unwrap() + .clone(); + let r = app.rect(&project); + draw(&mut app); + click(&mut app, r.x + 12, r.y + 2); + assert_eq!(app.last_command, Some(Command::Code)); + plain(&mut app, K::Esc); + menu(&mut app, Command::NewForm); + draw(&mut app); + let (text, _) = draw(&mut app); + assert!(text.contains("Property:")); + assert!(!text.contains("")); +} + +#[test] +fn export_dialogs_validate_and_render_all_results_without_creating_files() { + let t = Temp::new(); + let mut app = t.app(); + let stamp = ProjectStamp::capture(&app.project); + for command in [Command::MakeExe, Command::MakeLibrary] { + menu(&mut app, command); + while app.dialog.as_ref().unwrap().focus != 1 { + plain(&mut app, K::Tab); + } + plain(&mut app, K::Right); + let system = app.dialog.as_ref().unwrap().fields[1].string(); + plain(&mut app, K::Tab); + plain(&mut app, K::Right); + let architecture = app.dialog.as_ref().unwrap().fields[2].string(); + let (text, _) = draw(&mut app); + assert!(text.contains("Erzeugen: Phase 6")); + field(&mut app, 3, ""); + plain(&mut app, K::Enter); + assert!(app.dialog.as_ref().unwrap().error.contains("Ausgabepfad")); + field(&mut app, 3, &t.0.join("notthere/out").display().to_string()); + plain(&mut app, K::Enter); + assert!(app + .dialog + .as_ref() + .unwrap() + .error + .contains("Zielverzeichnis")); + field(&mut app, 3, &t.0.join("bad.tbc").display().to_string()); + plain(&mut app, K::Enter); + assert!(app.dialog.as_ref().unwrap().error.contains("TBC")); + let existing = t.0.join("existing"); + fs::write(&existing, b"keep").unwrap(); + field(&mut app, 3, &existing.display().to_string()); + plain(&mut app, K::Enter); + assert!(app + .dialog + .as_ref() + .unwrap() + .error + .contains("Überschreibentscheidung")); + plain(&mut app, K::Tab); + plain(&mut app, K::Char(' ')); + plain(&mut app, K::Enter); + assert_eq!(fs::read(&existing).unwrap(), b"keep"); + assert!(app.dialog.as_ref().unwrap().request.is_some()); + let target = t.0.join("result"); + field(&mut app, 3, &target.display().to_string()); + plain(&mut app, K::Enter); + let request = app.dialog.as_ref().unwrap().request.clone().unwrap(); + assert_eq!(request.system, system); + assert_eq!(request.architecture, architecture); + assert_eq!(request.project, stamp); + for status in [ + ExportStatus::Running, + ExportStatus::Success, + ExportStatus::Failed("Testfehler".into()), + ExportStatus::Cancelled, + ] { + app.export_result(&request, status.clone()).unwrap(); + let (text, _) = draw(&mut app); + assert!(text.contains(&status.text()), "{text}"); + assert!(!target.exists()); + } + plain(&mut app, K::Esc); + assert_eq!(ProjectStamp::capture(&app.project), stamp); + } +} + +#[test] +fn file_picker_save_conflict_and_project_replacement_use_real_events() { + let t = Temp::new(); + let mut app = t.app(); + let member = t.0.join("existing.bas"); + fs::write(&member, "PRINT 2\n").unwrap(); + menu(&mut app, Command::AddFile); + plain(&mut app, K::F(2)); + let DialogKind::Browse { entries, .. } = &app.dialog.as_ref().unwrap().kind else { + panic!("Dateiwahl fehlt") + }; + let index = entries.iter().position(|p| p == &member).unwrap(); + for _ in 0..index { + plain(&mut app, K::Right); + } + plain(&mut app, K::Enter); + assert!(matches!( + app.dialog.as_ref().unwrap().kind, + DialogKind::AddFile + )); + plain(&mut app, K::Enter); + let id = app.active_document().unwrap(); + type_text(&mut app, "'edit\n"); + fs::write(&member, "external\n").unwrap(); + menu(&mut app, Command::SaveFile); + plain(&mut app, K::Enter); + assert!(app + .dialog + .as_ref() + .unwrap() + .error + .contains("Überschreibentscheidung")); + assert_eq!(fs::read_to_string(&member).unwrap(), "external\n"); + plain(&mut app, K::Tab); + plain(&mut app, K::Char(' ')); + plain(&mut app, K::Enter); + assert!(app.dialog.is_none()); + assert!(!app.project.document(id).unwrap().is_dirty()); + menu(&mut app, Command::LoadText); + plain(&mut app, K::F(2)); + plain(&mut app, K::Esc); + assert!(matches!( + app.dialog.as_ref().unwrap().kind, + DialogKind::LoadText + )); + plain(&mut app, K::Esc); + let path = t.0.join("sub/p.mak"); + fs::create_dir(path.parent().unwrap()).unwrap(); + fs::write(&path, "main.bas\n").unwrap(); + fs::write(path.parent().unwrap().join("main.bas"), "END\n").unwrap(); + menu(&mut app, Command::OpenProject); + field(&mut app, 0, &path.display().to_string()); + plain(&mut app, K::Enter); + assert!(matches!( + app.dialog.as_ref().unwrap().kind, + DialogKind::Dirty(_) + )); + plain(&mut app, K::Right); + plain(&mut app, K::Right); + plain(&mut app, K::Enter); + assert!(app.dialog.is_none()); + assert_eq!(app.project.path(), Some(path.as_path())); + menu(&mut app, Command::NewProject); + assert!(app.dialog.is_none()); + assert_eq!(app.project.members().len(), 1); + assert_eq!(app.project.directory(), path.parent().unwrap()); +} + +#[test] +fn exit_with_partial_save_retains_dialog_and_unsaved_documents() { + let t = Temp::new(); + fs::write(t.0.join("a.bas"), "END\n").unwrap(); + fs::write(t.0.join("b.bas"), "END\n").unwrap(); + let mak = t.0.join("p.mak"); + fs::write(&mak, "a.bas\nb.bas\n").unwrap(); + let mut app = t.app(); + app.load_initial_project(mak.clone()).unwrap(); + let ids = app.project.members(); + for id in &ids { + app.project.replace_text(*id, 0..0, "' changed\n").unwrap(); + } + let b = t.0.join("b.bas"); + let original = fs::metadata(&b).unwrap().permissions(); + let mut perms = original.clone(); + perms.set_readonly(true); + fs::set_permissions(&b, perms).unwrap(); + key(&mut app, K::F(4), M::ALT); + plain(&mut app, K::Right); + plain(&mut app, K::Enter); + plain(&mut app, K::Enter); + assert!(app + .dialog + .as_ref() + .unwrap() + .error + .contains("schreibgeschützt")); + assert!(!app.quit); + assert!(!app.project.document(ids[0]).unwrap().is_dirty()); + assert!(app.project.document(ids[1]).unwrap().is_dirty()); + assert_eq!(fs::read_to_string(&mak).unwrap(), "a.bas\nb.bas\n"); + fs::set_permissions(&b, original).unwrap(); + plain(&mut app, K::Enter); + assert!(app.quit); + assert!(!app.project.is_dirty()); +} + +#[test] +fn mouse_focus_is_modal_and_program_abort_is_not_editor_copy() { + let t = Temp::new(); + let mut app = t.app(); + let active = app.active; + menu(&mut app, Command::NewModule); + draw(&mut app); + click(&mut app, 85, 2); + assert_eq!(app.active, active); + plain(&mut app, K::Esc); + menu(&mut app, Command::Output); + app.execution = Execution::Running; + key(&mut app, K::Char('c'), M::CONTROL); + assert_eq!( + app.basic_events, + vec![Event::Key(KeyEvent::new(K::Char('c'), M::CONTROL))] + ); + let r = app.rect(app.active_window().unwrap()); + let mouse = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: r.x + 2, + row: r.y + 2, + modifiers: M::NONE, + }; + app.handle(Event::Mouse(mouse)); + assert_eq!(app.basic_events.last(), Some(&Event::Mouse(mouse))); + app.execution = Execution::Idle; + menu(&mut app, Command::RightMouse); + plain(&mut app, K::Char(' ')); + plain(&mut app, K::Enter); + let before = app.last_command; + app.handle(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 2, + row: 2, + modifiers: M::NONE, + })); + assert_eq!(app.last_command, before); + menu(&mut app, Command::RightMouse); + plain(&mut app, K::Char(' ')); + plain(&mut app, K::Enter); + app.handle(Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 2, + row: 2, + modifiers: M::NONE, + })); + assert_eq!(app.last_command, Some(Command::Topic)); +} + +#[test] +fn designer_form_list_and_context_availability_match_the_active_window() { + let t = Temp::new(); + fs::write( + t.0.join("Form1.frm"), + "VERSION 1.00\nBegin Form Existing\nEnd\n", + ) + .unwrap(); + let mut app = t.app(); + menu(&mut app, Command::NewForm); + assert_eq!( + app.project + .document(app.active_document().unwrap()) + .unwrap() + .source_path() + .file_name() + .unwrap(), + "Form2.frm" + ); + let entries = app.window_commands(); + assert_eq!(entries.len(), 1); + assert!(entries[0].0.starts_with("• Form:")); + plain(&mut app, K::F(11)); + key(&mut app, K::Char('h'), M::ALT); + assert_eq!(app.menus()[app.menu.unwrap().0].title, "Help"); + plain(&mut app, K::Esc); + menu(&mut app, Command::Code); + plain(&mut app, K::Enter); + menu(&mut app, Command::Project); + assert!(app + .availability(Command::LoadText) + .unwrap() + .contains("Codefenster")); +} + +#[test] +fn clicking_project_members_and_dialog_choices_transfers_focus_and_applies_values() { + let t = Temp::new(); + let mut app = t.app(); + menu(&mut app, Command::NewForm); + menu(&mut app, Command::Code); + plain(&mut app, K::Enter); + menu(&mut app, Command::Project); + draw(&mut app); + let r = app.rect(app.active_window().unwrap()); + // Zweites Mitglied ist das Formular; der Listenklick bindet Enter an Project. + click(&mut app, r.x + 3, r.y + 5); + assert_eq!(app.active_window().unwrap().kind, WindowKind::Project); + plain(&mut app, K::Enter); + assert_eq!(app.mode, Mode::Designer); + menu(&mut app, Command::RightMouse); + draw(&mut app); + let hit = app + .hits + .iter() + .find(|(_, h)| matches!(h, tb_ide::app::Hit::DialogField(0))) + .unwrap() + .0; + click(&mut app, hit.x, hit.y); + plain(&mut app, K::Enter); + assert!(!app.options.right_help); +} + +#[test] +fn options_apply_persist_across_bases_and_preserve_unsaved_values_on_error() { + let t = Temp::new(); + let mut app = t.app(); + menu(&mut app, Command::Display); + // Titelhintergrund: Palette 5 -> 6, Desktopzeichen und Tabweite. + while app.dialog.as_ref().unwrap().focus != 7 { + plain(&mut app, K::Tab); + } + plain(&mut app, K::Right); + field(&mut app, 14, "."); + field(&mut app, 15, "4"); + plain(&mut app, K::Enter); + assert!(app.dialog.is_none()); + let (_, b) = draw(&mut app); + assert_eq!(b[(4, 1)].bg, Color::Yellow); + assert_eq!(app.options.tab_width, 4); + let include = t.0.join("includes"); + fs::create_dir(&include).unwrap(); + fs::write(include.join("shared.bi"), "PRINT 42\n").unwrap(); + menu(&mut app, Command::Paths); + field(&mut app, 2, &include.display().to_string()); + plain(&mut app, K::Enter); + let id = app.active_document().unwrap(); + app.project + .replace_text(id, 0..0, "'$INCLUDE: 'shared.bi'\n") + .unwrap(); + assert!(app.project.sources().unwrap().units[0] + .segments + .iter() + .any(|s| s.text == "PRINT 42\n")); + menu(&mut app, Command::SyntaxChecking); + assert!(!app.options.syntax_checking); + menu(&mut app, Command::SaveOptions); + plain(&mut app, K::Enter); + assert!(app.dialog.is_none()); + let elsewhere = t.0.join("elsewhere"); + fs::create_dir(&elsewhere).unwrap(); + let fresh = App::new(&elsewhere, app.config_path.clone(), (80, 25)).unwrap(); + assert_eq!(fresh.options, app.options); + let mut perms = fs::metadata(&app.config_path).unwrap().permissions(); + let original = perms.clone(); + perms.set_readonly(true); + fs::set_permissions(&app.config_path, perms).unwrap(); + menu(&mut app, Command::SyntaxChecking); + menu(&mut app, Command::SaveOptions); + plain(&mut app, K::Enter); + assert!(app + .dialog + .as_ref() + .unwrap() + .error + .contains("schreibgeschützt")); + assert_ne!(app.options, app.saved_options); + fs::set_permissions(&app.config_path, original).unwrap(); + fs::write( + &app.config_path, + "version=1\ntab_width=0\ncolor.3=999,5\ndesktop=xx\nfuture=foo\n", + ) + .unwrap(); + let (defaults, errors, _) = Options::load(&app.config_path); + assert_eq!(defaults, Options::default()); + assert_eq!(errors.len(), 4); +} diff --git a/docs/ide-bedienung.md b/docs/ide-bedienung.md new file mode 100644 index 0000000..f9ec3f5 --- /dev/null +++ b/docs/ide-bedienung.md @@ -0,0 +1,164 @@ +# Terminal Basic IDE bedienen + +`cargo run -p tb-ide -- [Projekt.mak|Modul.bas|Formular.frm]` startet die IDE. +Ohne Datei erscheint ein neues Projekt mit `[1] Untitled` und dem Project +Window rechts. Versions- und Copyright-Begrüßung blockieren keine Eingabe. +Bei bereits vorhandener `Untitled.bas` bekommt das neue Dokument einen +freien Namen. Es wird beim Start keine Datei angelegt. + +Das Terminal muss mindestens 80×25 Zellen haben. Darunter erscheint nur +der Größenhinweis; Dokumente, Fokus und Fensterzustand bleiben erhalten. +Beim Vergrößern werden die Fenster wieder innerhalb der Terminalfläche +dargestellt. Normales Beenden und Fehlerpfade stellen Raw Mode, Cursor, +Mausmodus und Alternate Screen zurück. + +## Menüs, Fenster und Dialoge + +- Alt plus Menübuchstabe oder F11 öffnet die Menüleiste. Pfeile wählen + Menü/Eintrag, Enter führt aus; der unterstrichene Buchstabe wählt direkt. + Esc bricht ab. F11 ist die Alternative, wenn das Terminal Alt allein + nicht meldet. Menüs sind auch anklickbar, Help steht rechts. +- File enthält Projekt- und Dateiaktionen einschließlich New Form, + New Module, Add/Remove File, Save File/As, Load/Save Text und Exit. + Run → Set Start-up File speichert eine Auswahl oder den bisherigen Standard. +- Dialoge binden Tastatur und Maus an sich. Tab/Shift+Tab wechselt das Feld, + Links/Rechts ändert Auswahlwerte, Leertaste schaltet Kästchen. Textfelder + unterstützen Cursor, Pos1/Ende, Backspace/Entf und Ctrl+A zum Ersetzen. + Enter/OK übernimmt, Esc/Abbrechen verwirft den Dialog. +- F2 in einem Dateipfad öffnet die Dateiauswahl. Links/Rechts wählt einen + Eintrag, Enter öffnet ein Verzeichnis oder übernimmt eine Datei. Esc + kehrt zum vorherigen Dialog mit dessen Angaben zurück. Neue Dateinamen + lassen sich direkt ins Pfadfeld schreiben. +- F6/Ctrl+F6 bzw. Shift+F6 wechselt Fenster. Ctrl+F4 schließt eine Ansicht; + Dokument und Änderungen bleiben im Projektmodell erhalten. Alt+Minus + oder die Control-Menü-Box öffnet die Fensterbefehle. +- Ctrl+F7 verschiebt und Ctrl+F8 skaliert mit Pfeilen (Ctrl+Pfeile: fünf + Zellen); Enter bestätigt, Esc stellt die vorige Geometrie wieder her. + Ctrl+F9 minimiert, Ctrl+F10 maximiert, Ctrl+F5 stellt die Normalgeometrie + wieder her. Window → Arrange All kachelt, New Window öffnet eine weitere + Ansicht desselben Dokuments. Die Window-Liste markiert die aktive Ansicht. +- Im Project Window wählen Pfeile das Mitglied; Enter öffnet Code oder + Formular entsprechend dem Dateityp. Form/Code-Buttons öffnen die gewählte + Ansicht. View → Code/Form bietet zusätzlich einen Auswahldialog. +- Die Umgebung zeigt unten anklickbare Tastenkürzel und `00001:001` als + Zeile/Spalte. Der Designer besitzt stattdessen die Properties Bar. + F10 wechselt dort zwischen Menü- und Properties Bar, F2 fokussiert Value. + Die fachliche Formularbearbeitung folgt in Change 05. + +## Speichern und Wechseln + +Save File nutzt den bestehenden Pfad; neue Dateien, Importziele und +Speicherkonflikte öffnen den Speicherdialog. Save Project zeigt Projektziel +und Dokumentziele mit separaten Überschreibentscheidungen. Eine Änderung +eines Pfades allein autorisiert kein Überschreiben einer vorhandenen Datei. +Binäre FRM-Originale bleiben auch mit gesetzter Überschreibentscheidung +geschützt; für bearbeitete Importe ist ein anderes Textziel erforderlich. + +Beim Projektwechsel und Beenden stehen Abbrechen, Speichern und Verwerfen +zur Auswahl. Abbrechen ist vorausgewählt. Bei einem Teilspeicherfehler +bleibt der Dialog mit allen Angaben offen: erfolgreich gespeicherte Dateien +sind gespeichert, übrige Änderungen bleiben erhalten; die IDE schließt +nicht. Die MAK-Datei wird zuletzt geschrieben. Details stehen in +[Dateiformate](dateiformate.md#bearbeitete-dokumente-und-speichern). + +Load Text fügt am Cursor als eine Undo-Aktion ein; Save Text exportiert im +Rahmen den gesamten Code. Der Dokumentvertrag unterstützt bereits +Auswahlexport; die Auswahlbedienung gehört zum Editor-Change 03. Remove +File löscht keine Datei; beim Entfernen der Startdatei ist die Wahl eines +Ersatzes oder des bisherigen Standards ausdrücklich im Dialog enthalten. + +## Display und Benutzereinstellungen + +Options → Display bietet Vorder- und Hintergrund (0–15) für Menü, Desktop, +Code, aktiven Titel, Rahmen, Dialog/Projekt und Status. Default ist Schwarz +auf Hellgrau für Menüs/Dialogs, Hellgrau auf Blau für Code, Weiß auf Magenta +für aktive Titel und Schwarz auf Cyan für den Status. Desktop: `▒`, +Hellgrau auf Schwarz; Tabweite: 8. Ein Desktopzeichen muss genau eine +Terminalzelle belegen, Tabweite liegt zwischen 1 und 32. Änderungen gelten +sofort für die IDE; BASIC-Laufzeitfarben bleiben unabhängig. + +Set Paths verwaltet Quellen-, Ausgabe- und Include-Verzeichnisse. Mehrere +Includes werden mit `|` getrennt; relative Optionspfade werden beim +Übernehmen relativ zum Projektverzeichnis absolut aufgelöst. Das Include +neben der Quelldatei hat Vorrang vor Suchverzeichnissen. Right Mouse +schaltet Kontext-Hilfe per Rechtsklick (Default an); die Hilfefunktion +folgt in Change 07. Syntax Checking ist zunächst an; dessen fachliche +Editorwirkung folgt in Change 03. + +Erst Options → Save… / OK schreibt die versionierte Benutzerdatei: + +| System | Pfad | +| --- | --- | +| Unix mit absolutem XDG_CONFIG_HOME | `$XDG_CONFIG_HOME/TerminalBasic/options.ini` | +| macOS ohne XDG_CONFIG_HOME | `$HOME/Library/Application Support/TerminalBasic/options.ini` | +| anderes Unix ohne XDG_CONFIG_HOME | `$HOME/.config/TerminalBasic/options.ini` | +| Windows | `%APPDATA%/TerminalBasic/options.ini` | + +Die Datei ist unabhängig vom Arbeitsverzeichnis. Ungültige Werte erhalten +eine Diagnose und behalten die Vorgabe; unbekannte Schlüssel werden mit +Hinweis ignoriert. Unbekannte Versionen/ungültige Textkodierung verwenden +Defaults. Schreibfehler erhalten die aktuellen, ungespeicherten Optionen. +Externe Änderungen der Optionsdatei werden vor dem Ersetzen erkannt. + +## Native Exporte: UI in Phase 5, Erzeugung in Phase 6 + +Run → Make EXE File… (Alt+R, E) und Make Library… (Alt+R, L) öffnen +vollständig bedienbare Dialoge. Artefaktart ist das native eigenständige +Executable bzw. die native Systembibliothek. Zielsystem (Linux/macOS/Windows) +und Architektur (x86_64/aarch64) sind auswählbar; Vorgabe ist das laufende +Zielsystem. Ausgabepfad und Überschreibentscheidung werden je Auftrag +erfasst. Die Auswahl ist noch keine Zusage einer vom Backend unterstützten +Kombination; die Phase-6-Plattformmatrix bestätigt die tatsächlichen Ziele. + +Prüfen/Enter validiert die Angaben und schützt Projektdatei, geöffnete +Dokumente und binäre FRM-Originale. Leere/ungültige Pfade, fehlende +Zielverzeichnisse, bestehende Ziele ohne Zustimmung und `.tbc` als natives +Ziel ergeben eine Diagnose; die anderen Angaben bleiben erhalten. + +Nur **Erzeugen** ist bis Phase 6 gesperrt, mit dem Hinweis „Native +Erzeugung folgt in Phase 6“. Ein Produktionsaufruf schreibt keine Datei +und meldet keinen erfundenen Erfolg. Der vorbereitete Auftrag trägt +Projektidentität und Revisionsstand, Artefakt, Zielsystem, Architektur, +Pfad und Überschreibentscheidung. Die UI kann zu diesem Auftrag Laufend, +Erfolg, Fehler und Abbruch darstellen. Oberflächentests speisen diese +Ergebnisse kontrolliert ein; sie belegen keine native Erzeugung. + +Die tatsächliche Erzeugung und reale Backend-Anbindung, native Formate, +Library-Symbole/Aufrufvertrag und der native Verbraucher-Nachweis stehen +explizit in [PLAN.md, Phase 6](../PLAN.md). Ein umbenanntes TBC erfüllt +keines der beiden Exportziele. + +## Befehlsabdeckung nach Change 02 + +Alle Referenzbefehle sind in ihrer Menüfolge vorhanden. Fachbefehle der +folgenden Changes sind sichtbar deaktiviert und zeigen einen +Verfügbarkeitsgrund; ihre Kürzel gelangen nicht versehentlich an BASIC. +Ctrl+C im Editor ist Kopieren (Fachfunktion 03), bei laufendem Programm mit +Output-Fokus wird Ctrl+C an den Programmeingabepfad weitergereicht. + +| Bereich | Rahmenaktion jetzt | Fachaktion im Folgechange | +| --- | --- | --- | +| File | New/Open/Save Project, New Form/Module, Add/Remove File, Save File/As, Load/Save Text, Exit | Print/Shell: 04 | +| Edit | Undo über Dokumentaktionen; einfacher Texteinstieg | Cut/Copy/Paste/Clear, New Sub/Function: 03; Event Procedures: 05 | +| View | Code/Form-Auswahl, Designer Menu Bar/Value-Fokus | Included File/Lines: 03; Output Screen: 04; Grid Lines: 05; Next Statement: 06 | +| Search | Vollständiges Menü und Kürzelzuordnung | Find, Selected Text, Repeat Last Find, Change: 03 | +| Run | Set Start-up File und beide Make-Dialoge | Start/Restart/Continue/COMMAND$ und Startup-Ausführungswirkung: 04; native Erzeugung: Phase 6 | +| Debug | Vollständiges Menü und Kürzelzuordnung, einschließlich F7/F8/F9/F10 und History | Watches, Watchpoints, Trace, History, Breakpoints, Break on Errors, Set Next Statement: 06 | +| Options | Display, Set Paths, Right Mouse, Save, Syntax Checking | Syntaxwirkung: 03; Kontext-Hilfe: 07 | +| Window | New Window, Arrange All, Calls/Debug/Help/Immediate/Output/Project, aktive Codeliste, alle Fensterkürzel | Output-Inhalt: 04; Debug/Calls/Immediate-Inhalte: 06; Hilfeinhalt: 07 | +| Designer Tools/Window | Tools-Menü, Formliste und gemeinsamer Fenster-/Modusrahmen | Controls/Toolbox, Color Palette, Menu Design Window: 05 | +| Help | Vollständiges Menü, About und Help-Fensterrahmen | Index, Contents, Keyboard, Topic, Using Help, Tutorial: 07 | + +Der einfache Texteinstieg ist noch kein abgeschlossener Editor: Navigation, +Selektion, Zwischenablage, Syntaxprüfung und inkrementelles Übersetzen +werden in 03 vollständig umgesetzt. Die gesamte Phase-5-Abnahme erfolgt +erst in Change 08. + +## Reproduzierbarer Testpfad + +`cargo test -p tb-ide` prüft denselben `App::handle(Event)`-Dispatcher und +dieselbe `App::render`-Funktion wie der Terminalprozess mit +`ratatui::TestBackend`. Die Tests bedienen Menü, Tastatur, Dialog und Maus; +sie prüfen Fokus, Dokumente, Fenster, sichtbare Texte und Farben. Dieser +Pfad steht den Folgechanges zur Verfügung. `TerminalGuard` prüft zusätzlich +Normalende, Fehlerende und teilweise fehlgeschlagene Initialisierung. diff --git a/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md b/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md index 3fd299a..4ac9641 100644 --- a/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md +++ b/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md @@ -29,7 +29,7 @@ Besondere Risiken sind unverändert ausgeführte alte Kompilate nach einer Text | Nr. | Change | Direkt benötigte Vorgänger | Ergebnis | |---|---|---|---| | 01 | [Projekt- und Dokumentmodell](proposal.md) | keine | Gemeinsame Lade-/Speicheraktionen, bearbeitete Quellen, MAK und Startup-Metadaten | -| 02 | [IDE-Rahmen](../../phase-5-02-ide-rahmen/proposal.md) | 01 | Terminalbesitz, Menüs, Fenster, Dialoge, Optionen und Eingabetestpfad | +| 02 | [IDE-Rahmen](../2026-09-06-phase-5-02-ide-rahmen/proposal.md) | 01 | Terminalbesitz, Menüs, Fenster, Dialoge, Optionen und Eingabetestpfad | | 03 | [Editor und inkrementelle Übersetzung](../../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md) | 01, 02 | Vollständige Codebearbeitung, Diagnose und schneller aktueller Compile | | 04 | [Ausführung und Output](../../phase-5-04-ausfuehrung-und-output/proposal.md) | 01, 02, 03 | Fortsetzbare VM-Sitzung, Reset, Ausgabe, Shell und Textdruck | | 05 | [Formular-Designer](../../phase-5-05-formular-designer/proposal.md) | 01, 02, 03 | Visuelles Gestalten und konsistenter Ereigniscode | diff --git a/openspec/changes/phase-5-02-ide-rahmen/.openspec.yaml b/openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/.openspec.yaml similarity index 100% rename from openspec/changes/phase-5-02-ide-rahmen/.openspec.yaml rename to openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/.openspec.yaml diff --git a/openspec/changes/phase-5-02-ide-rahmen/design.md b/openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/design.md similarity index 100% rename from openspec/changes/phase-5-02-ide-rahmen/design.md rename to openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/design.md diff --git a/openspec/changes/phase-5-02-ide-rahmen/proposal.md b/openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md similarity index 83% rename from openspec/changes/phase-5-02-ide-rahmen/proposal.md rename to openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md index f24deaf..bfeb6c6 100644 --- a/openspec/changes/phase-5-02-ide-rahmen/proposal.md +++ b/openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md @@ -24,6 +24,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt crates/tb-ide/src/main.rs sowie IDE-interne App-, Fenster- und Dialogmodule; vorhandenes ratatui/crossterm und tb-ui-Rendering. Der BASIC-Tastenpfad bleibt von IDE-Kürzeln getrennt. -**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md). +**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md). -**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. +**Gesamtplanung:** [Phase-5-Übersicht](../2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. diff --git a/openspec/changes/phase-5-02-ide-rahmen/specs/ide-oberflaeche/spec.md b/openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/specs/ide-oberflaeche/spec.md similarity index 100% rename from openspec/changes/phase-5-02-ide-rahmen/specs/ide-oberflaeche/spec.md rename to openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/specs/ide-oberflaeche/spec.md diff --git a/openspec/changes/phase-5-02-ide-rahmen/tasks.md b/openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/tasks.md similarity index 70% rename from openspec/changes/phase-5-02-ide-rahmen/tasks.md rename to openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/tasks.md index e57999d..755b14b 100644 --- a/openspec/changes/phase-5-02-ide-rahmen/tasks.md +++ b/openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/tasks.md @@ -1,28 +1,28 @@ ## 1. Terminalrahmen und Befehle -- [ ] 1.1 Den tb-Platzhalter durch App-Zustand, Renderloop und einen Terminal-Guard ersetzen; headless Starttest zeigt Untitled-Code, Project Window und nicht blockierende Begrüßung, Guard-Test belegt Cleanup auch im Fehlerpfad. -- [ ] 1.2 Die zwei Menüsätze und Befehls-/Verfügbarkeitstabelle aus der Referenz erfassen; ein Tabellenabgleich bestätigt Reihenfolge, Mnemonics und vollständige Einträge. -- [ ] 1.3 Den Dispatcher für Alt/F11, Menüs, Dialoge, Fenster/Modi und spätere BASIC-Zustellung implementieren; Eventtests belegen Esc-Abbruch und eindeutige Verarbeitung von F2/F10/F11/F12/Ctrl+C. -- [ ] 1.4 Dialoggrundfunktionen für Auswahl, Texteingabe, Dateiwahl und Bestätigungen mit den Aktionen aus 01 verbinden; Eventtests belegen Fokusbindung, Abbrechen und Erhalt geänderter Dokumente. +- [x] 1.1 Den tb-Platzhalter durch App-Zustand, Renderloop und einen Terminal-Guard ersetzen; headless Starttest zeigt Untitled-Code, Project Window und nicht blockierende Begrüßung, Guard-Test belegt Cleanup auch im Fehlerpfad. +- [x] 1.2 Die zwei Menüsätze und Befehls-/Verfügbarkeitstabelle aus der Referenz erfassen; ein Tabellenabgleich bestätigt Reihenfolge, Mnemonics und vollständige Einträge. +- [x] 1.3 Den Dispatcher für Alt/F11, Menüs, Dialoge, Fenster/Modi und spätere BASIC-Zustellung implementieren; Eventtests belegen Esc-Abbruch und eindeutige Verarbeitung von F2/F10/F11/F12/Ctrl+C. +- [x] 1.4 Dialoggrundfunktionen für Auswahl, Texteingabe, Dateiwahl und Bestätigungen mit den Aktionen aus 01 verbinden; Eventtests belegen Fokusbindung, Abbrechen und Erhalt geänderter Dokumente. ## 2. Fensterverwaltung und Darstellung -- [ ] 2.1 Code-/Projekt- und Werkzeugfenster mit Control-Menü, Rahmen, Scrollbalken und eigener Geometrie implementieren; TestBackend-Snapshots prüfen Titelnummern, aktive Markierung und Projektschaltflächen. -- [ ] 2.2 New Window, Arrange All und sämtliche F6-/Ctrl-Fensterbefehle aus der Spec anbinden; eine Eingabefolge belegt Wechseln, Schließen, Bewegen, Größe, Minimieren, Maximieren und Wiederherstellen ohne Dokumentverlust. -- [ ] 2.3 Das referenzierte DOS-Farbschema und klickbare Statuskürzel mit Zeile/Spalte darstellen; Snapshots und Klicktests belegen Farben, Position und dieselbe Befehlswirkung wie die Tastatur. -- [ ] 2.4 Terminal-Resize und Mindestgröße 80×25 behandeln; Tests schrumpfen unter die Grenze und zurück und prüfen erreichbare Fenster, erhaltenen Fokus und Dokumente. -- [ ] 2.5 Den Designer-Modus mit Properties-Bar-Platz und ohne Statuszeile anbinden; ein Moduswechseltest erhält das Dokument und zeigt den richtigen Menüsatz. +- [x] 2.1 Code-/Projekt- und Werkzeugfenster mit Control-Menü, Rahmen, Scrollbalken und eigener Geometrie implementieren; TestBackend-Snapshots prüfen Titelnummern, aktive Markierung und Projektschaltflächen. +- [x] 2.2 New Window, Arrange All und sämtliche F6-/Ctrl-Fensterbefehle aus der Spec anbinden; eine Eingabefolge belegt Wechseln, Schließen, Bewegen, Größe, Minimieren, Maximieren und Wiederherstellen ohne Dokumentverlust. +- [x] 2.3 Das referenzierte DOS-Farbschema und klickbare Statuskürzel mit Zeile/Spalte darstellen; Snapshots und Klicktests belegen Farben, Position und dieselbe Befehlswirkung wie die Tastatur. +- [x] 2.4 Terminal-Resize und Mindestgröße 80×25 behandeln; Tests schrumpfen unter die Grenze und zurück und prüfen erreichbare Fenster, erhaltenen Fokus und Dokumente. +- [x] 2.5 Den Designer-Modus mit Properties-Bar-Platz und ohne Statuszeile anbinden; ein Moduswechseltest erhält das Dokument und zeigt den richtigen Menüsatz. ## 3. Native Exporte vorbereiten -- [ ] 3.1 Make EXE File und Make Library als vollständige Dialoge mit Zielsystem/-architektur und Ausgabepfad implementieren; Menü-/Tastaturtests öffnen beide Dialoge und bearbeiten alle Angaben. -- [ ] 3.2 Eingabeprüfung und Überschreibentscheidung an die geschützten Dateiaktionen aus 01 anbinden; Tests prüfen leere/ungültige Pfade, bestehende Ziele, Abbrechen und erhaltene übrige Angaben. -- [ ] 3.3 Konkrete Exportaufträge und Ergebnis-/Statusdarstellung vorbereiten, im Produktionspfad bis Phase 6 nur Erzeugen sperren; kontrollierte Rückmeldungen prüfen Laufend/Erfolg/Fehler/Abbruch und ein realer Phase-5-Aufruf erzeugt keine Datei oder falsche Erfolgsmeldung. -- [ ] 3.4 Export-UI, native Zielartefakte und die Phase-6-Backend-Grenze in der Bedienungsdokumentation festhalten; Abgleich mit den expliziten PLAN-Punkten bestätigt, dass TBC nicht als natives Executable oder Library angeboten wird. +- [x] 3.1 Make EXE File und Make Library als vollständige Dialoge mit Zielsystem/-architektur und Ausgabepfad implementieren; Menü-/Tastaturtests öffnen beide Dialoge und bearbeiten alle Angaben. +- [x] 3.2 Eingabeprüfung und Überschreibentscheidung an die geschützten Dateiaktionen aus 01 anbinden; Tests prüfen leere/ungültige Pfade, bestehende Ziele, Abbrechen und erhaltene übrige Angaben. +- [x] 3.3 Konkrete Exportaufträge und Ergebnis-/Statusdarstellung vorbereiten, im Produktionspfad bis Phase 6 nur Erzeugen sperren; kontrollierte Rückmeldungen prüfen Laufend/Erfolg/Fehler/Abbruch und ein realer Phase-5-Aufruf erzeugt keine Datei oder falsche Erfolgsmeldung. +- [x] 3.4 Export-UI, native Zielartefakte und die Phase-6-Backend-Grenze in der Bedienungsdokumentation festhalten; Abgleich mit den expliziten PLAN-Punkten bestätigt, dass TBC nicht als natives Executable oder Library angeboten wird. ## 4. Optionen und Integrationsübergabe -- [ ] 4.1 Display-, Pfad-, Rechtsklick- und Syntax-Checking-Dialoge implementieren und benutzerbezogen speichern; Tests prüfen geänderte Farben/Tabweite, Include-Fallback, Defaults und Neustart aus anderem Arbeitsverzeichnis. -- [ ] 4.2 Ungültige Konfiguration und nicht schreibbare Optionsdatei diagnostizieren; Tests erhalten bedienbare Defaults beziehungsweise ungespeicherte Einstellungen ohne falsche Erfolgsmeldung. -- [ ] 4.3 Den headless App-Testpfad für nachfolgende Changes verfügbar machen und Fachbefehle bis zu ihrer Umsetzung mit erklärter Verfügbarkeit versehen; die Abdeckungstabelle unterscheidet Rahmenaktion und noch ausstehende Featureaktion. -- [ ] 4.4 Bedienung und Referenzdefaults dokumentieren und alle Oberflächen-Spec-Szenarien in verification.md zuordnen; cargo test -p tb-ide und Formatprüfung müssen bestehen. +- [x] 4.1 Display-, Pfad-, Rechtsklick- und Syntax-Checking-Dialoge implementieren und benutzerbezogen speichern; Tests prüfen geänderte Farben/Tabweite, Include-Fallback, Defaults und Neustart aus anderem Arbeitsverzeichnis. +- [x] 4.2 Ungültige Konfiguration und nicht schreibbare Optionsdatei diagnostizieren; Tests erhalten bedienbare Defaults beziehungsweise ungespeicherte Einstellungen ohne falsche Erfolgsmeldung. +- [x] 4.3 Den headless App-Testpfad für nachfolgende Changes verfügbar machen und Fachbefehle bis zu ihrer Umsetzung mit erklärter Verfügbarkeit versehen; die Abdeckungstabelle unterscheidet Rahmenaktion und noch ausstehende Featureaktion. +- [x] 4.4 Bedienung und Referenzdefaults dokumentieren und alle Oberflächen-Spec-Szenarien in verification.md zuordnen; cargo test -p tb-ide und Formatprüfung müssen bestehen. diff --git a/openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/verification.md b/openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/verification.md new file mode 100644 index 0000000..74017fd --- /dev/null +++ b/openspec/changes/archive/2026-09-06-phase-5-02-ide-rahmen/verification.md @@ -0,0 +1,142 @@ +# Verifizierung: Phase 5 – IDE-Rahmen + +Stand: 2026-09-06. Basis: `687fc230ecf84963e517fa78033f0fdc86554744` plus +Implementierung dieses Changes. Geprüft wurden Proposal, Design, alle +Tasks, die Delta-Spec `ide-oberflaeche` und `docs/ide-referenz.md` gegen +Code, Ereignisfolgen, gerenderte TestBackend-Puffer und den Terminalprozess. + +## Ergebnis + +| Dimension | Ergebnis | +| --- | --- | +| Vollständigkeit | 17/17 Tasks, 7/7 Anforderungen und 11/11 Szenarien abgedeckt | +| Korrektheit | Bedienung über echte crossterm-Ereignisse, Zustandsprüfungen und Ratatui-Puffer bestätigt | +| Kohärenz | Ein Terminalbesitzer, gemeinsamer Dispatcher, Dokumentmodell aus 01 und konkrete Auftrags-/Ergebnisübergabe | +| Offene Befunde | 0 CRITICAL, 0 WARNING, 0 SUGGESTION | + +Die Verifizierung wurde nach den unten genannten Korrekturen wiederholt. +Keine Prüfdimension wurde ausgelassen. + +## Anforderungen und Implementierung + +Alle Pfade beziehen sich auf die Repository-Wurzel. + +| Anforderung | Implementierung | +| --- | --- | +| Start und zwei Arbeitsmodi | `crates/tb-ide/src/main.rs`: tatsächlicher Terminal-/Renderloop; `app.rs::App::new`, `show_document` und `render.rs::App::render`: Untitled, rechtes Project Window, Begrüßung, Environment/Designer und Properties Bar ohne Designer-Statuszeile. | +| Einheitliche Menü- und Befehlsbedienung | `commands.rs::menus` und `Command::feature_phase`: beide vollständigen Menüsätze und Verfügbarkeit; `app.rs::{handle,key,click,shortcut,execute,availability}`: Modal → Menü → Fenster/Modus → globale Aktionen → Programmeingabe. `render.rs` verwendet dieselben Command-Werte für Statusklicks und Menüs, zeigt Mnemonics und deaktivierte Befehle mit Grund. | +| MDI-Fenster und Fokus | `app.rs`: `Window`, `WindowKind`, `WindowState`, `cycle`, `arrange`, `rect`, `window_commands` und Fensteraktionen; Dokumentansichten aus 01 bleiben gemeinsam. `render.rs`: Titel, Control-Menü-Box, Rahmen, Scrollbalken, Min-/Max-Buttons und Projektbuttons. | +| Farben und Statusanzeige | `options.rs::Options` und `render.rs::{dos,expanded}`: sieben UI-Elemente, DOS-Palette, Desktopzeichen und Tabweite; aktive Titel Weiß/Magenta, Code Blau, Status Schwarz/Cyan und Zeile/Spalte. Farben liegen ausschließlich im IDE-Zustand. | +| Beständige und validierte Optionen | `options.rs::{config_path,Options::load,Options::validate,Options::save}`; Dialoge in `app.rs::submit`. Versionierte benutzerbezogene Datei, Defaults/Diagnosen, unbekannte Schlüssel, explizites Save, absolute Pfade und Include-Suchpfade an das Dokumentmodell. Speicherung verwendet den geschützten Einzeldatei-Schreiber aus 01. | +| Terminalgröße und Wiederherstellung | `app.rs::handle` und `render.rs::render` behandeln Resize und die physische 80×25-Grenze; `terminal.rs::TerminalGuard` besitzt Raw Mode, Alternate Screen, Mausmodus und Cursor einschließlich teilweise fehlgeschlagener Initialisierung. | +| Vollständige Exportdialoge mit getrennter Erzeugung | `export.rs`: `Artifact`, `ProjectStamp`, `ExportRequest`, `ExportStatus`; `app.rs::{submit,export_result}` und `render.rs` bilden beide Dialoge samt Prüfung und Ergebnissen ab. `documents.rs::Project::validate_export_target` verwendet die vorhandenen Ziel-/Binärschutzprüfungen. Erzeugen bleibt im Produktionspfad mit Phase-6-Hinweis gesperrt. | + +## Nachweise je Spec-Szenario + +Die genannten Oberflächentests stehen in `crates/tb-ide/tests/app.rs`. +Sie bedienen `App::handle` mit Tastatur-/Mausereignissen und rendern über +TestBackend; die Prüfung beschränkt sich nicht auf direkte Command-Aufrufe. + +| Szenario | Nachweis | +| --- | --- | +| Erster Start | `start_snapshot_and_reference_menus_have_all_commands`: Untitled, Project-Titel, Begrüßung und Status im Puffer; unmittelbare Texteingabe. Menüfolge, Referenznamen und eindeutige Mnemonics geprüft. Zusätzlich tatsächlicher Start im Pseudoterminal. | +| Modusabhängige Funktionstaste | `dispatcher_keeps_function_keys_and_copy_out_of_basic_input`: F10 wechselt im Designer Menü/Properties; im pausierten Environment wird ProcedureStep ausgewählt. Die eigentliche Debuggeraktion ist gemäß Design bis Change 06 sichtbar deaktiviert. | +| Vollständige Eingabeverteilung | Derselbe Test prüft Ctrl+C/Copy, F11 und F12/Event Procedures ohne BASIC-Zustellung; `mouse_focus_is_modal_and_program_abort_is_not_editor_copy` prüft Ctrl+C und Maus bei laufendem Programm mit Output-Fokus sowie die Right-Mouse-Option. | +| Fenster wiederherstellen | `windows_restore_geometry_and_resize_without_document_loss`: zusätzliche Ansicht, Verschieben/Skalieren, Min-/Max-/Restore, Control-Menü, Fensterwechsel und Schließen ohne Dokumentverlust. Normalgeometrie bleibt erhalten; Fensteraktionen verändern keine Dokumentcursor. | +| Dialogfokus | `modal_focus_and_dirty_project_cancel_preserve_document` und `mouse_focus_is_modal_and_program_abort_is_not_editor_copy`: Tasten/Maus bleiben modal, Abbruch erhält aktives Fenster und Text; Save/Discard/Cancel folgen dem Dokumentvertrag. | +| Farben ändern | `options_apply_persist_across_bases_and_preserve_unsaved_values_on_error`: Titelhintergrund, Desktopzeichen und Tabweite werden im Display-Dialog geändert; Titelzelle im gerenderten Puffer geprüft. Runtime-Farben werden von dieser ausschließlich app-lokalen Palette nicht verändert. | +| Neustart mit Optionen | Derselbe Test speichert Optionen, startet App mit anderer Projektbasis und derselben Benutzerdatei und prüft identische Werte; Include-Fallback, Syntax-Default/Toggle, ungültige Konfiguration und schreibgeschützte Optionsdatei sind enthalten. `config_path` verwendet ausschließlich absolute Benutzerverzeichnisse und keine CWD-Konfigurationsdatei. | +| Verkleinern und zurück | `windows_restore_geometry_and_resize_without_document_loss`: 40×12 zeigt nur den Hinweis, danach 80×25 mit gleichem Fokus, unveränderten Dokumenten und begrenzten Fensterrechtecken. | +| Dialoge vor dem Erzeugungsbackend | `export_dialogs_validate_and_render_all_results_without_creating_files`: beide Make-Menüs, bearbeitete System-/Architektur-/Pfadfelder, sichtbare Erzeugen-Sperre. Zusätzlich wurden beide Dialoge im echten `tb` über Alt+R/E bzw. Alt+R/L geöffnet und mit Enter geprüft; keine Dateiausgabe. | +| Fehlerhafte Ausgabeangaben | Derselbe Exporttest prüft leeren Pfad, fehlendes Verzeichnis, `.tbc`, vorhandenes Ziel ohne Zustimmung und ausdrückliche Zustimmung; andere Felder bleiben erhalten. Das bestehende Ziel bleibt im Phase-5-Produktionspfad unverändert. | +| Ergebnisdarstellung über die vorbereitete Übergabe | Derselbe Exporttest übergibt Running, Success, Failed und Cancelled für denselben geprüften Auftrag; sichtbare Statusausgabe und unveränderte Projektidentität/-revision geprüft. Diese kontrollierten UI-Ergebnisse sind kein Nachweis nativer Erzeugung. | + +Zusätzliche Integrationsnachweise: + +- `file_picker_save_conflict_and_project_replacement_use_real_events`: + Dateiauswahl, externe Änderung, explizites Überschreiben, Abbruch der + Dateiauswahl und Wechsel in ein Projekt in anderem Verzeichnis. +- `exit_with_partial_save_retains_dialog_and_unsaved_documents`: + Fehler beim zweiten Mitglied hält den Dialog offen und verhindert Exit; + erstes Mitglied ist gespeichert, zweites dirty, MAK unverändert; + Wiederholung nach Beheben des Schreibschutzes beendet erfolgreich. +- `status_and_project_buttons_use_the_keyboard_command_path` und + `clicking_project_members_and_dialog_choices_transfers_focus_and_applies_values`: + Statusklick, Form/Code-Buttons, Fokusübergabe bei Listenklick, Enter und + anklickbare Dialogauswahl/-kästchen. +- `designer_form_list_and_context_availability_match_the_active_window`: + Formularliste statt BAS-Liste im Designer, eindeutige freie Formnamen, + Alt-Menüwechsel und deaktivierter Textimport ohne Codefenster. +- `terminal::tests::terminal_cleanup_on_normal_error_and_partial_initialization`: + Guard-Cleanup bei Normalende, propagiertem Fehler und fehlgeschlagener + Initialisierung; alle 14 Dokumentmodelltests aus 01 bleiben grün. + +## Behobene Befunde im Implementierungs- und Prüflauf + +- Der Projekttitel wurde durch die Fensterbuttons überdeckt; das rechte + Anfangsfenster bietet jetzt genügend Platz für Titel und Bedienelemente. +- Doppelte Menü-Mnemonics wurden eindeutig vergeben und im Test abgesichert; + Mnemonics werden auch im aufgeklappten Menü unterstrichen dargestellt. +- Ctrl+F10 wird im Designer als Fensteraktion behandelt; F10 allein + wechselt auch aus einem aktiven Menü zurück zur Properties Bar. +- Ctrl+C und Mausereignisse bei Programmfokus gelangen an die vorbereitete + Programmeingabe; Editor-Copy und reservierte IDE-Tasten bleiben getrennt. +- Projektbuttons verwenden das gewählte Projektmitglied. Listenklicks + aktivieren das Project Window, sodass Enter dort die gewählte Ansicht + öffnet. Auswahlfelder und Kästchen reagieren auf Mausklicks. +- Die Designer-Fensterliste enthält ausschließlich Formulare; Textimport + ist ohne Codefenster mit erkennbarem Grund deaktiviert. Neue Dokumente + und Formulare vermeiden Namenskollisionen mit vorhandenen Dateien. +- Dialogtexte zeigen einen Cursor und scrollen bei langen Eingaben; die + Dateiauswahl kehrt bei Abbruch zu den erhaltenen Eingaben zurück. + +Der Test für einen leeren Exportpfad wurde zudem so korrigiert, dass er +nach Ctrl+A tatsächlich Delete sendet; Auswahl allein löscht keinen Text. +Keine Anforderung wurde dafür abgeschwächt und kein Test deaktiviert. + +## Ausgeführte Abschlussprüfungen + +| Prüfung | Ergebnis | +| --- | --- | +| `cargo test --workspace` nach den letzten Codekorrekturen | 514 bestanden, 0 fehlgeschlagen, 2 bereits ignorierte optionale Tests | +| IDE innerhalb dieses Laufs | 12 App-Tests, 1 Guard-Test und 14 Dokumenttests bestanden | +| `cargo fmt --all -- --check` | bestanden | +| `cargo clippy -p tb-ide --all-targets -- -D warnings` | bestanden | +| `cargo build -p tb-ide` | bestanden | +| Echter `target/debug/tb` in einem isolierten Pseudoterminal | Start, beide Make-Dialoge/Prüfen, Exit/Verwerfen, Exitcode 0; termios-Ausgangszustand wiederhergestellt; Cursor/Maus/Alternate-Screen zurückgesetzt, keine Dateien im temporären Arbeitsverzeichnis | +| `openspec validate --all --strict --json` | 23/23 gültig: 16 Hauptspecs und 7 Changes | +| `git diff --check`, Whitespace neuer Dateien und Dokumentverweise | bestanden | + +Die zwei unverändert ignorierten Tests erzeugen manuell Golden Files bzw. +benötigen den zusätzlichen externen VBDOS-Referenzcheckout. Neue Tests +verwenden absolute temporäre Pfade; der Pseudoterminalprozess hat ein +eigenes temporäres Arbeitsverzeichnis. Kein globaler Rust-CWD-Wechsel und +keine Benutzerkonfiguration wurden für die Prüfung verändert. + +## Übergabe und Phasengrenzen + +`docs/ide-bedienung.md` enthält Bedienung, Defaults, Optionspfade, +Exportgrenze und eine vollständige Befehlsabdeckung mit Zuständigkeiten. +Editor 03, Ausführung 04, Designer-Fachwerkzeuge 05, Debugger 06 und Hilfe +07 bleiben entsprechend dem ausdrücklichen Design über benannte +Verfügbarkeitsgründe deaktiviert. Der Dispatcher wählt bereits ihre +Befehle, behauptet aber keine ausgeführte Fachfunktion. + +Die UI nutzt vorhandenes ratatui/crossterm und das vorhandene +`unicode-width`-Workspace-Paket für Terminalzellen; keine neue Crate und +kein Plugin-/Widgetframework wurden eingeführt. FormFile, Quellenlader, +SavePlan und geschützte Datei-Ersetzung stammen aus 01. Native Formate, +Library-ABI und reale Erzeugungsanbindung bleiben bei den dafür vorgesehenen +Phase-6-Punkten in PLAN.md. Dies sind geplante Change-Grenzen und keine +offenen Befunde dieses Changes. Synchronisation, Archivierung und +Commit/Push sind nicht Teil dieser Implementierungsverifizierung. + +## Synchronisation und Archivierung + +Am 2026-09-06 wurde die Hauptspec `ide-oberflaeche` mit allen sieben +Anforderungen und elf Szenarien synchronisiert. Der Vergleich mit der +Delta-Spec bestätigt vollständige Inhaltsgleichheit bei kanonischer +Hauptspec-Struktur. Vor der Archivierung bestanden 17/17 Hauptspecs die +strikte Validierung, danach 23/23 aktive Einträge (17 Hauptspecs und sechs +Changes). Die durch die Archivierung betroffenen Links in Folgechanges +und Phase-5-Übersicht wurden angepasst und auf vorhandene Ziele geprüft. diff --git a/openspec/changes/phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md b/openspec/changes/phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md index 3dd6f42..7ae50f9 100644 --- a/openspec/changes/phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md +++ b/openspec/changes/phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md @@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt tb-ide-Editor, tb-frontend Lexer/Parser/Sema und tb-vm Projektcompiler/Linker; bestehende Compile-Budget-Tests und docs/tbvm-design.md. Kein zweiter BASIC-Parser und kein Optimizer. -**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md). +**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md). **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. diff --git a/openspec/changes/phase-5-04-ausfuehrung-und-output/proposal.md b/openspec/changes/phase-5-04-ausfuehrung-und-output/proposal.md index b72c1b5..220560a 100644 --- a/openspec/changes/phase-5-04-ausfuehrung-und-output/proposal.md +++ b/openspec/changes/phase-5-04-ausfuehrung-und-output/proposal.md @@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt tb-ide-Sitzung, tb-vm/interp.rs, CLI-run_chain, tb-runtime/host.rs und builtins.rs sowie tb-ui/host.rs und screen.rs. Vorhandene Ereignisse, TextScreen und CLI-Ausführung bleiben die Grundlage. -**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). +**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. diff --git a/openspec/changes/phase-5-05-formular-designer/proposal.md b/openspec/changes/phase-5-05-formular-designer/proposal.md index cc3dd60..4a14500 100644 --- a/openspec/changes/phase-5-05-formular-designer/proposal.md +++ b/openspec/changes/phase-5-05-formular-designer/proposal.md @@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt tb-ide-Designer, vorhandenes tb-ui::frm::FormFile, FormsModel und tb-frontend-Forms-Metadaten. Keine zweite FRM-Implementierung und keine Programmausführung als Designvorschau. -**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). +**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. diff --git a/openspec/changes/phase-5-07-hilfesystem/proposal.md b/openspec/changes/phase-5-07-hilfesystem/proposal.md index 8b13fa9..33d7838 100644 --- a/openspec/changes/phase-5-07-hilfesystem/proposal.md +++ b/openspec/changes/phase-5-07-hilfesystem/proposal.md @@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt tb-ide-Hilfe, docs/ und Paketierung von tb; nutzt Fensterverwaltung und Editor-Kontext. Kein Webdienst und keine eigene zweite Dokumentationssammlung. -**Abhängigkeiten:** [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). +**Abhängigkeiten:** [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. diff --git a/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md b/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md index 2ec3389..6c25906 100644 --- a/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md +++ b/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md @@ -24,6 +24,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt tb-ide-Integrationstests, vorhandene CLI-/VM-/Forms-Tests und Benchmarks, docs/ sowie PLAN.md. Featureimplementierung bleibt jeweils beim verantwortlichen Change; dieser Change schließt Integration und Nachweise. -**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../phase-5-07-hilfesystem/proposal.md). +**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../phase-5-07-hilfesystem/proposal.md). **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. diff --git a/openspec/specs/ide-oberflaeche/spec.md b/openspec/specs/ide-oberflaeche/spec.md new file mode 100644 index 0000000..8da5ecb --- /dev/null +++ b/openspec/specs/ide-oberflaeche/spec.md @@ -0,0 +1,72 @@ +# ide-oberflaeche Specification + +## Purpose + +Definiert den bedienbaren Terminalrahmen der IDE mit zwei Arbeitsmodi, gemeinsamen Befehlen, Fenstern, Dialogen und beständigen Benutzereinstellungen. + +## Requirements + +### Requirement: Start und zwei Arbeitsmodi +Die IDE SHALL mit Versions-/Copyright-Begrüßung und anschließend einem leeren Projekt mit Codefenster „[1] Untitled“ und rechts angeordnetem Project Window starten. Programmierumgebung und Designer SHALL im selben Prozess arbeiten. Die Umgebung SHALL File/Edit/View/Search/Run/Debug/Options/Window und rechts Help zeigen; der Designer File/Edit/View/Tools/Options/Window/Help. Der Designer SHALL eine Properties Bar statt einer Statuszeile besitzen. + +#### Scenario: Erster Start +- **WHEN** die IDE ohne Projekt gestartet wird +- **THEN** ist das leere Codefenster direkt bedienbar und das Project Window sichtbar; die Begrüßung blockiert keine Eingabe + +### Requirement: Einheitliche Menü- und Befehlsbedienung +Alle Befehle aus docs/ide-referenz.md SHALL im jeweils beschriebenen Menü erscheinen. Alt oder F11 SHALL die Menüleiste aktivieren, Mnemonics auswählen und Esc abbrechen; mit „…“ bezeichnete Befehle SHALL einen Dialog öffnen. Menü, Kürzel und anklickbarer Statushinweis SHALL dieselbe Aktion auslösen. Kontextuell unzulässige Befehle SHALL deaktiviert und ihr Grund erkennbar sein. + +#### Scenario: Modusabhängige Funktionstaste +- **WHEN** F10 im Designer und danach im pausierten Programmiermodus gedrückt wird +- **THEN** wechselt es im Designer zwischen Menü- und Properties Bar und löst im Programmiermodus den Prozedurschritt aus + +#### Scenario: Vollständige Eingabeverteilung +- **WHEN** Ctrl+C im Editor, F11 zur Menüaktivierung und F12 im Formularkontext gedrückt werden +- **THEN** werden Kopieren, Menüaktivierung und Event Procedures ausgelöst, ohne als BASIC-Abbruch oder verlorene Taste behandelt zu werden + +### Requirement: MDI-Fenster und Fokus +Fenster SHALL Titelleiste, Control-Menü, Rahmen und bei Bedarf Scrollbalken erhalten. New Window SHALL eine zusätzliche Ansicht erzeugen; Arrange All SHALL geöffnete Fenster kacheln. F6/Ctrl+F6, Shift+F6, Ctrl+F4, Ctrl+F7/F8/F9/F10/F5 und Alt+Minus SHALL Wechseln, Schließen, Verschieben, Skalieren, Minimieren, Maximieren, Wiederherstellen und Control-Menü bedienen. Die Window-Liste SHALL das aktive Codefenster markieren. Projektbuttons Form/Code und Enter SHALL die entsprechende Ansicht öffnen. + +#### Scenario: Fenster wiederherstellen +- **WHEN** ein Codefenster verschoben, skaliert, minimiert, maximiert und wiederhergestellt wird +- **THEN** bleiben Dokument, Cursor und vorherige Normalgeometrie erhalten und alle Bedienelemente erreichbar + +#### Scenario: Dialogfokus +- **WHEN** ein modaler Dialog geöffnet und anschließend abgebrochen wird +- **THEN** empfängt nur der Dialog die Eingaben und danach erhält das vorher aktive Fenster den Fokus zurück + +### Requirement: Farben und Statusanzeige +Die IDE SHALL das Farbschema aus docs/ide-referenz.md verwenden, insbesondere blaue Codefläche, weiße aktive Titel auf Magenta, graue Menüs und schwarze Statusschrift auf Cyan. Die Statuszeile SHALL kontextuelle klickbare Kürzel und Zeile/Spalte im Format 00001:001 zeigen. Display SHALL Vorder-/Hintergrund aus 16 Farben, Desktop-Füllzeichen und Tabweite ändern können. + +#### Scenario: Farben ändern +- **WHEN** der Benutzer Titelhintergrund, Desktopzeichen und Tabweite im Display-Dialog ändert +- **THEN** erscheinen die Änderungen in der IDE, während die Farbvorgaben des ausgeführten BASIC-Programms unverändert bleiben + +### Requirement: Beständige und validierte Optionen +Options SHALL Display, Set Paths, Right Mouse, Save und Syntax Checking anbieten. Syntax Checking SHALL anfangs aktiv sein. Rechtsklick SHALL konfigurierbar Kontext-Hilfe auslösen. Explizites Save SHALL die Optionen benutzerbezogen speichern; fehlerhafte oder unbekannte Konfiguration SHALL mit verständlicher Diagnose und benutzbaren Vorgaben behandelt werden. + +#### Scenario: Neustart mit Optionen +- **WHEN** Optionen gespeichert und die IDE aus einem anderen Arbeitsverzeichnis neu gestartet wird +- **THEN** gelten die gespeicherten Werte weiterhin, ohne Projektdateien als Konfigurationsspeicher zu benutzen + +### Requirement: Terminalgröße und Wiederherstellung +Die IDE SHALL die aktuelle Terminalfläche nutzen und auf Größenänderungen reagieren. Unter 80×25 SHALL ausschließlich ein Mindestgrößenhinweis erscheinen, ohne Dokumente oder Sitzung zu verlieren. Normaler Exit und Fehlerabbruch SHALL Terminalmodus und Cursor wiederherstellen. + +#### Scenario: Verkleinern und zurück +- **WHEN** ein Terminal mit offenen Dokumenten unter 80×25 schrumpft und anschließend wieder wächst +- **THEN** kehrt die bedienbare Oberfläche mit erhaltenen Dokumenten und erreichbaren Fenstern zurück + +### Requirement: Vollständige Exportdialoge mit getrennter Erzeugung +Run→Make EXE File und Run→Make Library SHALL bereits in Phase 5 bedienbare Dialoge für Artefaktart, Zielsystem/-architektur und Ausgabepfad öffnen. Die Ziele SHALL als natives eigenständiges Executable beziehungsweise native Systembibliothek beschrieben sein. Die Dialoge SHALL Eingaben prüfen, Überschreibkonflikte behandeln und Zustände für Erzeugung, Erfolg, Fehler und Abbruch darstellen können. Die tatsächliche Erzeugung SHALL erst mit dem Backend aus Phase 6 verfügbar sein; bis dahin SHALL nur die Erzeugungsaktion mit einem verständlichen Verfügbarkeitsgrund gesperrt sein. Die IDE MUST NOT einen Export vortäuschen oder ein bloßes TBC-Kompilat als natives Artefakt ausgeben. + +#### Scenario: Dialoge vor dem Erzeugungsbackend +- **WHEN** beide Make-Befehle in Phase 5 über Menü oder Tastatur geöffnet werden +- **THEN** lassen sich Zielsystem, Architektur und Ausgabepfad bearbeiten und validieren; die fehlende Erzeugung wird erklärt und keine Zieldatei geschrieben + +#### Scenario: Fehlerhafte Ausgabeangaben +- **WHEN** ein leerer oder unzulässiger Ausgabepfad beziehungsweise ein bestehendes Ziel ohne Überschreibentscheidung angegeben wird +- **THEN** zeigt der Dialog den Eingabefehler oder die notwendige Entscheidung und behält die übrigen Angaben + +#### Scenario: Ergebnisdarstellung über die vorbereitete Übergabe +- **WHEN** die Exportanbindung im Oberflächentest Erzeugung, Erfolg, Fehler und Abbruch mit demselben gültigen Auftrag zurückmeldet +- **THEN** zeigt die IDE jeweils den passenden Zustand mit Ziel beziehungsweise Diagnose und erhält die Projektdokumente; der Test behauptet keine tatsächliche native Dateierzeugung