diff --git a/Cargo.lock b/Cargo.lock index bab6084..721a83b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -591,6 +591,7 @@ version = "0.1.0" dependencies = [ "anyhow", "tb-frontend", + "tb-ide", "tb-runtime", "tb-ui", "tb-vm", diff --git a/crates/tb-cli/Cargo.toml b/crates/tb-cli/Cargo.toml index e6a3c40..765f65e 100644 --- a/crates/tb-cli/Cargo.toml +++ b/crates/tb-cli/Cargo.toml @@ -16,3 +16,6 @@ tb-vm.workspace = true tb-runtime.workspace = true tb-ui = { workspace = true, features = ["terminal"] } anyhow.workspace = true + +[dev-dependencies] +tb-ide = { path = "../tb-ide" } diff --git a/crates/tb-cli/tests/project.rs b/crates/tb-cli/tests/project.rs index f51fc08..6771350 100644 --- a/crates/tb-cli/tests/project.rs +++ b/crates/tb-cli/tests/project.rs @@ -234,3 +234,38 @@ fn explicit_startup_order_and_form_selection_survive_tbc() { } std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn designer_frm_is_accepted_by_the_standalone_cli_compiler() { + use tb_frontend::forms::ObjectClass; + use tb_ide::{app::App, commands::Command as IdeCommand, documents::Destination}; + let dir = std::env::temp_dir().join(format!("tb-designer-cli-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dir = dir.canonicalize().unwrap(); + let mut app = App::new(&dir, dir.join("options"), (100, 30)).unwrap(); + app.execute(IdeCommand::NewForm); + let root = app.design_objects().unwrap()[0].0; + let button = app.design_place(ObjectClass::CommandButton, 0, root, Default::default()); + assert!(button.is_err()); // ungültige Größe schreibt nichts + let mut rect = app.area(); + rect.x = 2; + rect.y = 2; + rect.width = 8; + rect.height = 2; + let control = app + .design_place(ObjectClass::CommandButton, 0, root, rect) + .unwrap(); + app.design_property("INDEX", "0").unwrap(); + app.design_event(control, "CLICK").unwrap(); + let doc = app.active_document().unwrap(); + let path = dir.join("designed.frm"); + app.project + .save_file(doc, Some(&Destination::new(&path))) + .unwrap(); + let built = tbc(&dir, "build", "designed.frm"); + assert!(built.status.success(), "{built:?}"); + let bytes = std::fs::read(dir.join("designed.tbc")).unwrap(); + assert_eq!(&bytes[..6], b"TBC\0\x04\0"); + tb_vm::project_io::load_program(&dir.join("designed.tbc")).unwrap(); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/tb-frontend/src/forms.rs b/crates/tb-frontend/src/forms.rs index 38a5260..fa847b7 100644 --- a/crates/tb-frontend/src/forms.rs +++ b/crates/tb-frontend/src/forms.rs @@ -751,3 +751,17 @@ impl FormCatalog { false } } + +/// Tastenkombinationen, die der Forms-Host für Menü-Shortcuts liefert. +pub fn menu_shortcuts() -> Vec { + let mut out = vec![String::new()]; + for prefix in ["", "Ctrl+", "Alt+", "Shift+", "Ctrl+Shift+"] { + for n in 1..=10 { + out.push(format!("{prefix}F{n}")); + } + } + for c in ('A'..='Z').filter(|c| *c != 'C') { + out.push(format!("Ctrl+{c}")); + } + out +} diff --git a/crates/tb-frontend/src/sema.rs b/crates/tb-frontend/src/sema.rs index 3257ff9..dce3e4c 100644 --- a/crates/tb-frontend/src/sema.rs +++ b/crates/tb-frontend/src/sema.rs @@ -421,6 +421,23 @@ pub fn lower_with_forms( (hir, s.diags) } +/// Semantisch gebundene Objekt-/Ereignisnamen für transaktionale IDE-Umbenennungen. +/// Aufrufer validieren die gesamte Übersetzungseinheit einschließlich ihrer Imports. +#[derive(Default)] +pub struct BoundFormReferences { + pub objects: Vec<(SourcePos, String, u16)>, + pub procedures: Vec<(SourcePos, String)>, + pub diagnostics: Vec, +} +pub fn bound_form_references(module: &Module, catalog: &FormCatalog) -> BoundFormReferences { + let mut s = new_sema(module, catalog); + s.references = Some(BoundFormReferences::default()); + s.run(module); + let mut references = s.references.unwrap(); + references.diagnostics = s.diags; + references +} + fn new_sema(module: &Module, catalog: &FormCatalog) -> Sema { Sema { diags: Vec::new(), @@ -446,6 +463,7 @@ fn new_sema(module: &Module, catalog: &FormCatalog) -> Sema { forms: catalog.clone(), module_name: module.name.clone(), event_procs: Vec::new(), + references: None, } } @@ -550,6 +568,7 @@ struct Sema { forms: FormCatalog, module_name: String, event_procs: Vec, + references: Option, } impl Sema { @@ -942,6 +961,9 @@ impl Sema { { return; } + if let Some(r) = &mut self.references { + r.objects.push((proc.pos, proc.sig.name.clone(), object)); + } let mut expected: Vec<(&str, forms::EventParamType)> = Vec::new(); if self.forms.objects[object as usize].array { expected.push(("INDEX", forms::EventParamType::Integer)); @@ -1049,6 +1071,14 @@ impl Sema { ) -> Option<(u16, forms::FormObject)> { if let Some(parent) = parent { if let Some((id, object)) = self.forms.find_in(name, parent) { + if let Some(r) = &mut self.references { + r.objects.push((pos, name.into(), id)); + } + if let Some((form, _)) = self.forms.find(parent) { + if let Some(r) = &mut self.references { + r.objects.push((pos, parent.into(), form)); + } + } return Some((id, object.clone())); } self.err( @@ -1067,7 +1097,11 @@ impl Sema { pos: SourcePos, ) -> Option<(u16, crate::forms::FormObject)> { if let Some((id, object)) = self.find_object(name) { - return Some((id, object.clone())); + let object = object.clone(); + if let Some(r) = &mut self.references { + r.objects.push((pos, name.into(), id)); + } + return Some((id, object)); } self.err(pos, format!("Unknown object '{name}'")); None @@ -2391,7 +2425,11 @@ impl Sema { OptionKind::Base(b) => self.option_base = *b, }, Stmt::TypeDecl { .. } => {} // bereits in Pass 1 registriert - Stmt::Declare { .. } => {} // bereits in Pass 1 registriert + Stmt::Declare { sig, pos } => { + if let Some(r) = &mut self.references { + r.procedures.push((*pos, sig.name.clone())); + } + } // in Pass 1 registriert Stmt::Call { name, args, pos, .. } => { @@ -3377,6 +3415,9 @@ impl Sema { pos: SourcePos, out: &mut Vec, ) { + if let Some(r) = &mut self.references { + r.procedures.push((pos, name.into())); + } if name == "CLIPBOARD.ADDITEM" { if args.len() != 1 { self.err(pos, "Argument-count mismatch for CLIPBOARD.ADDITEM"); @@ -4834,6 +4875,9 @@ impl Sema { .map(|(id, info)| (id, info.clone())) { if args.is_none() || info.array { + if let Some(r) = &mut self.references { + r.objects.push((pos, name.into(), object)); + } let Ok(index) = self.object_index(object, args, scope, pos) else { return (HExpr::Int(0), Ty::Unknown); }; diff --git a/crates/tb-ide/src/app.rs b/crates/tb-ide/src/app.rs index 54e2444..96b335e 100644 --- a/crates/tb-ide/src/app.rs +++ b/crates/tb-ide/src/app.rs @@ -30,6 +30,9 @@ pub enum Execution { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WindowKind { + Toolbox, + Palette, + MenuDesign, Code(ViewId), Project, Output, @@ -108,7 +111,7 @@ impl Field { pub fn flag(&self) -> bool { matches!(self.value, FieldValue::Toggle(true)) } - fn key(&mut self, key: KeyEvent) { + pub(crate) fn key(&mut self, key: KeyEvent) { match &mut self.value { FieldValue::Toggle(value) => { if matches!(key.code, K::Char(' ') | K::Left | K::Right) { @@ -189,6 +192,11 @@ pub enum AfterSave { } #[derive(Debug, Clone)] pub enum DialogKind { + DesignProperty, + DesignPalette, + DesignMenu((String, Option)), + DesignObject(Vec), + DesignEvent(u64), Search { view: ViewId, selection: Option>, @@ -251,6 +259,10 @@ impl Dialog { } #[derive(Debug, Clone, Copy)] pub enum Hit { + DesignTool(usize), + DesignPaint(&'static str, u8), + DesignObject(u64), + DesignAction(&'static str), Command(Command), Menu(usize), MenuItem(usize), @@ -263,6 +275,7 @@ pub enum Hit { } pub struct App { + pub designer: crate::designer::Designer, pub session: crate::execution::Session, pub editor: crate::editor::Editor, pub project: Project, @@ -301,6 +314,7 @@ impl App { let (options, errors, config_disk) = Options::load(&config_path); project.include_paths = options.include_paths.clone(); let mut app = Self { + designer: Default::default(), session: Default::default(), editor: Default::default(), project, @@ -389,6 +403,9 @@ impl App { pub fn active_document(&self) -> Option { match self.active_window()?.kind { WindowKind::Code(v) => self.project.view(v).ok().map(|v| v.document()), + WindowKind::Toolbox | WindowKind::Palette | WindowKind::MenuDesign => { + self.designer.document + } _ => self.project.members().get(self.selected_member).copied(), } } @@ -477,30 +494,33 @@ impl App { .into(), ); } - if matches!( - command, - LoadText - | Print - | SaveText - | Cut - | Copy - | Paste - | Clear - | NewSub - | NewFunction - | IncludedFile - | IncludedLines - | Find - | SelectedText - | FindNext - | Replace - | Procedures - | PreviousCode - | Diagnostics - ) && !matches!( - self.active_window().map(|w| w.kind), - Some(WindowKind::Code(_)) - ) { + if !(self.mode == Mode::Designer && matches!(command, Cut | Copy | Paste | Clear)) + && matches!( + command, + LoadText + | Print + | SaveText + | Cut + | Copy + | Paste + | Clear + | NewSub + | NewFunction + | IncludedFile + | IncludedLines + | Find + | SelectedText + | FindNext + | Replace + | Procedures + | PreviousCode + | Diagnostics + ) + && !matches!( + self.active_window().map(|w| w.kind), + Some(WindowKind::Code(_)) + ) + { return Some("Kein Codefenster aktiv".into()); } if matches!( @@ -540,9 +560,19 @@ impl App { self.control_menu = false; if let Err(e) = self.action(command) { self.message = format!("{e:#}"); + if self.mode == Mode::Designer { + self.open_dialog( + "Designer", + DialogKind::Message, + vec![Field::text("Fehler", self.message.clone())], + ); + } } } fn action(&mut self, command: Command) -> Result<()> { + if self.designer_command(command)? { + return Ok(()); + } use Command::*; let id = self.active_document(); match command { @@ -742,6 +772,18 @@ impl App { FocusWindow(id) => { if self.windows.iter().any(|w| w.id == id) { self.active = id; + if self.mode == Mode::Designer { + if let Some(WindowKind::Code(v)) = self.active_window().map(|w| w.kind) { + let doc = self.project.view(v)?.document(); + if matches!(self.project.document(doc)?.content(), Content::Form(_)) { + self.design_enter(doc)?; + } else { + self.mode = Mode::Environment; + self.properties = false; + self.value_focus = false; + } + } + } } } Calls | Debug | HelpWindow | Immediate | Output | Project => { @@ -930,6 +972,22 @@ impl App { }; self.properties = form; self.value_focus = false; + if form { + self.design_enter(id)?; + if !self.windows.iter().any(|w| w.kind == WindowKind::Toolbox) { + let active = self.active; + self.add_window(WindowKind::Toolbox, Rect::new(0, 1, 20, 21)); + if let Some(w) = self.windows.iter_mut().find(|w| w.id == active) { + w.normal = Rect::new( + 20, + 1, + self.size.0.saturating_sub(20), + self.size.1.saturating_sub(1), + ); + } + self.active = active; + } + } Ok(()) } fn cycle(&mut self, direction: isize) { @@ -942,7 +1000,9 @@ impl App { .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; + if let Err(e) = self.action(Command::FocusWindow(self.windows[i].id)) { + self.message = format!("{e:#}"); + } } fn arrange(&mut self) { let area = self.area(); @@ -997,6 +1057,7 @@ impl App { } } self.session = Default::default(); + self.designer = Default::default(); self.basic_events.clear(); self.execution = Execution::Idle; self.base = self.project.directory().to_path_buf(); @@ -1072,6 +1133,14 @@ impl App { } fn submit(&mut self, d: &mut Dialog) -> Result { match d.kind.clone() { + DialogKind::DesignProperty + | DialogKind::DesignPalette + | DialogKind::DesignMenu(_) + | DialogKind::DesignObject(_) + | DialogKind::DesignEvent(_) => { + self.design_submit(d)?; + return Ok(false); + } DialogKind::Search { .. } | DialogKind::Procedure(_) | DialogKind::Procedures(_) @@ -1350,6 +1419,19 @@ impl App { } self.key(key); } else if let Event::Mouse(mouse) = event { + match self.design_mouse(mouse) { + Ok(true) => return, + Err(e) => { + self.message = format!("{e:#}"); + self.open_dialog( + "Designer", + DialogKind::Message, + vec![Field::text("Fehler", self.message.clone())], + ); + return; + } + _ => {} + } if self.dialog.is_none() && self.menu.is_none() && self.program_focus() { if self.session.fullscreen { self.basic_events.push(Event::Mouse(mouse)); @@ -1453,12 +1535,29 @@ impl App { return; } match hit { + Hit::DesignTool(_) => {} + Hit::DesignObject(id) => { + if let Err(e) = self.design_select(id, false) { + self.message = e.to_string(); + } + } + Hit::DesignAction(action) => { + if let Err(e) = self.design_action(action) { + self.message = format!("{e:#}"); + self.open_dialog( + "Designer", + DialogKind::Message, + vec![Field::text("Fehler", self.message.clone())], + ); + } + } + Hit::Command(c) => self.execute(c), Hit::Menu(i) => { self.properties = false; self.menu = Some((i, 0)); } - Hit::Window(id) => self.active = id, + Hit::Window(id) => self.execute(Command::FocusWindow(id)), Hit::ProjectMember(i) => { self.selected_member = i; if let Some(w) = self.windows.iter().find(|w| w.kind == WindowKind::Project) { @@ -1674,19 +1773,61 @@ impl App { 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(); + if let Err(e) = self.design_value_focus() { + self.message = format!("{e:#}"); + } return; } if self.program_focus() && key.modifiers.contains(M::CONTROL) && key.code == K::Char('c') { self.pause_execution(); return; } + if self.mode == Mode::Designer && self.value_focus { + if let Err(e) = self.design_key(key) { + self.message = format!("{e:#}"); + self.open_dialog( + "Property", + DialogKind::Message, + vec![Field::text("Fehler", self.message.clone())], + ); + } + return; + } + if matches!( + self.active_window().map(|w| w.kind), + Some(WindowKind::MenuDesign | WindowKind::Toolbox | WindowKind::Palette) + ) { + match self.design_key(key) { + Ok(true) => return, + Err(e) => { + self.message = format!("{e:#}"); + self.open_dialog( + "Designer", + DialogKind::Message, + vec![Field::text("Fehler", self.message.clone())], + ); + return; + } + _ => {} + } + } if let Some(command) = shortcut(key) { self.execute(command); return; } + match self.design_key(key) { + Ok(true) => return, + Err(e) => { + self.message = format!("{e:#}"); + self.open_dialog( + "Designer", + DialogKind::Message, + vec![Field::text("Fehler", self.message.clone())], + ); + return; + } + _ => {} + } if self.program_focus() { self.basic_events.push(Event::Key(key)); return; @@ -1783,7 +1924,7 @@ impl App { )) } fn code_key(&mut self, key: KeyEvent) -> Result<()> { - if self.editor_view().is_err() { + if self.mode == Mode::Designer || self.editor_view().is_err() { return Ok(()); } self.editor_key(key) @@ -1803,7 +1944,7 @@ impl App { let mut entries = menus[index] .items .iter() - .map(|i| (i.label.into(), i.command)) + .map(|i| (i.label.clone(), i.command)) .collect::>(); if menus[index].title == "Window" { entries.extend( diff --git a/crates/tb-ide/src/commands.rs b/crates/tb-ide/src/commands.rs index eafa531..93ee997 100644 --- a/crates/tb-ide/src/commands.rs +++ b/crates/tb-ide/src/commands.rs @@ -101,7 +101,6 @@ impl Command { pub fn feature_phase(self) -> Option { use Command::*; match self { - 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), @@ -113,7 +112,7 @@ impl Command { #[derive(Debug, Clone)] pub struct Item { /// & markiert das sichtbare Mnemonic, … einen Dialog. - pub label: &'static str, + pub label: String, pub command: Option, } impl Item { @@ -136,12 +135,12 @@ pub struct Menu { } pub fn menus(designer: bool) -> Vec { use Command::*; - let item = |label, command| Item { - label, + let item = |label: &str, command| Item { + label: label.into(), command: Some(command), }; let sep = || Item { - label: "────────", + label: "────────".into(), command: None, }; let menu = |title, mnemonic, items| Menu { @@ -211,27 +210,30 @@ pub fn menus(designer: bool) -> Vec { 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")), - ], - )); + result.push(menu("Tools", 't', { + let mut used = std::collections::BTreeSet::new(); + crate::designer::tools() + .into_iter() + .map(|(name, _, _)| { + let mut label = name.to_owned(); + if let Some((at, c)) = name + .char_indices() + .find(|(_, c)| !used.contains(&c.to_ascii_lowercase())) + { + used.insert(c.to_ascii_lowercase()); + label.insert(at, '&'); + } else { + let c = ('1'..='9').find(|c| !used.contains(c)).unwrap(); + used.insert(c); + label = format!("&{c} {label}"); + } + Item { + label, + command: Some(Tool(name)), + } + }) + .collect() + })); } else { result.extend([ menu( diff --git a/crates/tb-ide/src/designer.rs b/crates/tb-ide/src/designer.rs new file mode 100644 index 0000000..888e208 --- /dev/null +++ b/crates/tb-ide/src/designer.rs @@ -0,0 +1,1925 @@ +//! Designeraktionen auf dem gemeinsamen FRM-Dokument, ohne VM oder Ereignisschleife. +use crate::{ + app::{App, Dialog, DialogKind, Field, Hit, Mode, WindowKind}, + commands::Command, + documents::DocumentId, +}; +use anyhow::{anyhow, bail, ensure, Context, Result}; +use crossterm::event::{ + KeyCode as K, KeyEvent, KeyModifiers as M, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + widgets::Paragraph, + Frame, +}; +use std::collections::{BTreeMap, BTreeSet}; +use tb_frontend::{ + forms::{self, ObjectClass as Class, PropertyType}, + lexer::{self, TokenKind}, +}; +use tb_runtime::screen::TextScreen; +use tb_ui::{ + forms::{FormsModel, PropertyValue as Value}, + frm::{self, FormFile, FormNode}, +}; +use tb_vm::project_io::Content; + +pub type DesignId = u64; +type Key = (String, Option); +type Drag = (u16, u16, Option<(i8, i8)>); +#[derive(Default)] +pub struct Designer { + pub document: Option, + pub selections: BTreeMap>, + pub grid: bool, + pub tool: Option<(Class, i32)>, + pub property: usize, + pub value: Option, + clipboard: Vec, + pub viewport: Rect, + pub objects: Vec<(DesignId, Rect)>, + drag: Option, + paint: Option<(&'static str, u8)>, + last_tool_click: Option<(usize, std::time::Instant)>, +} +pub fn node_key(n: &FormNode) -> Key { + ( + n.name.to_uppercase(), + forms::property(n.class, "INDEX") + .and_then(|(p, _)| n.properties.get(&p)) + .and_then(|v| { + if let Value::Integer(n) = v { + Some(*n) + } else { + None + } + }), + ) +} +pub fn nodes(root: &FormNode) -> Vec<&FormNode> { + let mut out = vec![root]; + for n in &root.children { + out.extend(nodes(n)); + } + out +} +fn find_mut<'a>(n: &'a mut FormNode, key: &Key) -> Option<&'a mut FormNode> { + if node_key(n) == *key { + return Some(n); + } + n.children.iter_mut().find_map(|n| find_mut(n, key)) +} +pub fn value(n: &FormNode, name: &str) -> Option { + let (p, s) = forms::property(n.class, name)?; + Some( + n.properties + .get(&p) + .cloned() + .unwrap_or_else(|| Value::from_default(s.default, s.ty)), + ) +} +fn integer(n: &FormNode, name: &str) -> i32 { + match value(n, name) { + Some(Value::Integer(n)) => n, + _ => 0, + } +} +fn set(n: &mut FormNode, name: &str, v: Value) -> Result<()> { + let (p, _) = + forms::property(n.class, name).ok_or_else(|| anyhow!("{}.{} unbekannt", n.name, name))?; + n.properties.insert(p, v); + Ok(()) +} +fn text(v: &Value) -> String { + match v { + Value::Integer(v) => v.to_string(), + Value::Single(v) => v.to_string(), + Value::String(v) => v.clone(), + Value::Boolean(v) => v.to_string(), + _ => format!("{v:?}"), + } +} +fn container(c: Class) -> bool { + matches!(c, Class::Form | Class::Frame | Class::PictureBox) +} +/// Beide Spin-Stile sind Varianten derselben Metadatenklasse. +pub fn tools() -> Vec<(&'static str, Class, i32)> { + let mut out: Vec<_> = Class::ALL + .into_iter() + .filter(|c| !matches!(c, Class::Form | Class::Screen | Class::Menu | Class::Spin)) + .map(|c| (c.display_name(), c, 0)) + .collect(); + out.extend([("VSpin", Class::Spin, 0), ("HSpin", Class::Spin, 1)]); + out +} +pub fn preview(form: &FormFile, size: (u16, u16)) -> Result<(FormsModel, TextScreen)> { + let mut model = FormsModel::new(form.catalog().objects, size.0 as usize, size.1 as usize); + form.apply(&mut model)?; + // Timer bleiben ausschließlich im Dokument konfiguriert, nie in der Vorschau aktiv. + let enabled = forms::property(Class::Timer, "ENABLED").unwrap().0; + let catalog = form.catalog(); + for node in nodes(&form.root) + .into_iter() + .filter(|n| n.class == Class::Timer) + { + let object = catalog.find(&node.name).unwrap().0; + model.set_initial_at(object, node_key(node).1, enabled, Value::Boolean(false))?; + } + model.show(0, false)?; + // Es gibt weder eine VM noch einen Timer-/Event-Pump im Designer. + model.events.clear(); + let mut screen = TextScreen::with_size(size.0 as usize, size.1 as usize); + model.render(&mut screen); + Ok((model, screen)) +} +fn validate(form: &FormFile) -> Result<()> { + let parsed = frm::read_text("Designer", &frm::write_text(form))?; + let catalog = parsed.catalog(); + parsed.initial_values(&catalog)?; + fn visit(n: &FormNode, parent: Option<&FormNode>, depth: usize) -> Result<()> { + if let Some(p) = parent { + ensure!( + if n.class == Class::Menu { + matches!(p.class, Class::Menu | Class::Form) + } else { + container(p.class) + }, + "{}: ungültiger Container {}", + n.name, + p.name + ); + if !matches!(n.class, Class::Timer | Class::Menu) { + ensure!( + integer(n, "LEFT") + integer(n, "WIDTH") <= integer(p, "WIDTH") + && integer(n, "TOP") + integer(n, "HEIGHT") <= integer(p, "HEIGHT"), + "{}: Geometrie außerhalb von {}", + n.name, + p.name + ); + } + } + if n.class == Class::Menu { + ensure!(depth <= 6, "{}: höchstens sechs Menüebenen", n.name); + let shortcut = text(&value(n, "SHORTCUT").unwrap()); + ensure!( + shortcut.is_empty() + || forms::menu_shortcuts() + .iter() + .any(|s| s.eq_ignore_ascii_case(&shortcut.replace(' ', ""))), + "{}.Shortcut: nicht unterstützt", + n.name + ); + ensure!( + shortcut.is_empty() || parent.is_some_and(|p| p.class == Class::Menu), + "{}.Shortcut: kein Shortcut auf Menütitel", + n.name + ); + if value(n, "SEPARATOR") == Some(Value::Boolean(true)) + || value(n, "CAPTION") == Some(Value::String("-".into())) + { + ensure!( + n.children.is_empty() + && shortcut.is_empty() + && value(n, "CHECKED") == Some(Value::Boolean(false)) + && value(n, "ENABLED") == Some(Value::Boolean(true)), + "{}: ungültiger Separator", + n.name + ); + } + } + for child in &n.children { + visit( + child, + Some(n), + depth + usize::from(child.class == Class::Menu), + )?; + } + Ok(()) + } + visit(&parsed.root, None, 0) +} +impl App { + pub fn design_document(&self) -> Result { + self.active_window() + .and_then(|w| { + if let WindowKind::Code(v) = w.kind { + self.project.view(v).ok().map(|v| v.document()) + } else { + None + } + }) + .filter(|id| { + self.project + .document(*id) + .is_ok_and(|d| matches!(d.content(), Content::Form(_))) + }) + .or(self.designer.document) + .ok_or_else(|| anyhow!("Kein Formular ausgewählt")) + } + pub fn design_form(&self) -> Result<&FormFile> { + let Content::Form(f) = self.project.document(self.design_document()?)?.content() else { + bail!("Kein Formular") + }; + Ok(f) + } + pub fn design_enter(&mut self, id: DocumentId) -> Result<()> { + self.designer.document = Some(id); + let ids = self.project.design_ids(id)?.clone(); + let root = ids[&node_key(&self.design_form()?.root)]; + let selection = self.designer.selections.entry(id).or_default(); + selection.retain(|i| ids.values().any(|v| i == v)); + if selection.is_empty() { + selection.push(root); + } + self.designer.value = None; + Ok(()) + } + pub fn design_objects(&mut self) -> Result> { + let id = self.design_document()?; + let ids = self.project.design_ids(id)?.clone(); + Ok(nodes(&self.design_form()?.root) + .into_iter() + .map(|n| (ids[&node_key(n)], n.clone())) + .collect()) + } + pub fn design_select(&mut self, id: DesignId, multiple: bool) -> Result<()> { + ensure!( + self.design_objects()?.iter().any(|(i, _)| *i == id), + "Unbekanntes Designobjekt" + ); + let doc = self.design_document()?; + let selection = self.designer.selections.entry(doc).or_default(); + if !multiple { + selection.clear(); + } + if multiple && selection.contains(&id) { + selection.retain(|i| *i != id); + } else { + selection.push(id); + } + self.designer.property = 0; + self.designer.value = None; + self.value_focus = false; + Ok(()) + } + fn selected_keys(&mut self) -> Result> { + let doc = self.design_document()?; + let selected = self + .designer + .selections + .get(&doc) + .cloned() + .unwrap_or_default(); + Ok(self + .design_objects()? + .into_iter() + .filter(|(id, _)| selected.contains(id)) + .map(|(_, n)| node_key(&n)) + .collect()) + } + fn selected_node(&mut self) -> Result { + let key = self + .selected_keys()? + .last() + .cloned() + .ok_or_else(|| anyhow!("Kein Control ausgewählt"))?; + Ok(nodes(&self.design_form()?.root) + .into_iter() + .find(|n| node_key(n) == key) + .unwrap() + .clone()) + } + fn design_edit(&mut self, edit: impl FnOnce(&mut FormFile) -> Result<()>) -> Result<()> { + let id = self.design_document()?; + self.project.edit_form(id, |f| { + edit(f)?; + validate(f) + })?; + self.designer.value = None; + Ok(()) + } + pub fn design_place( + &mut self, + class: Class, + style: i32, + parent: DesignId, + rect: Rect, + ) -> Result { + ensure!( + !matches!(class, Class::Form | Class::Screen), + "Keine platzierbare Klasse" + ); + let objects = self.design_objects()?; + let p = objects + .iter() + .find(|(id, _)| *id == parent) + .ok_or_else(|| anyhow!("Container fehlt"))? + .1 + .clone(); + ensure!( + if class == Class::Menu { + matches!(p.class, Class::Menu | Class::Form) + } else { + container(p.class) + }, + "{} ist kein passender Container", + p.name + ); + let names: BTreeSet<_> = objects.iter().map(|(_, n)| n.name.to_uppercase()).collect(); + let name = (1..) + .map(|n| format!("{}{n}", class.display_name())) + .find(|n| !names.contains(&n.to_uppercase())) + .unwrap(); + let mut node = FormNode { + class, + name: name.clone(), + properties: BTreeMap::new(), + children: vec![], + }; + if !matches!(class, Class::Timer | Class::Menu) { + for (name, n) in [ + ("LEFT", rect.x), + ("TOP", rect.y), + ("WIDTH", rect.width), + ("HEIGHT", rect.height), + ] { + set(&mut node, name, Value::Integer(n as i32))?; + } + } + if class == Class::Spin { + ensure!((0..=1).contains(&style), "Spin.Style ungültig"); + set(&mut node, "STYLE", Value::Integer(style))?; + set( + &mut node, + "WIDTH", + Value::Integer(if style == 1 { 2 } else { 1 }), + )?; + set( + &mut node, + "HEIGHT", + Value::Integer(if style == 1 { 1 } else { 2 }), + )?; + } + if forms::property(class, "CAPTION").is_some() { + set(&mut node, "CAPTION", Value::String(name.clone()))?; + } + if forms::property(class, "TABINDEX").is_some() { + set( + &mut node, + "TABINDEX", + Value::Integer( + objects + .iter() + .map(|(_, n)| integer(n, "TABINDEX")) + .max() + .unwrap_or(0) + + 1, + ), + )?; + } + let key = node_key(&node); + self.design_edit(|f| { + find_mut(&mut f.root, &node_key(&p)) + .unwrap() + .children + .push(node); + Ok(()) + })?; + let doc = self.design_document()?; + let id = self.project.design_ids(doc)?[&key]; + self.design_select(id, false)?; + Ok(id) + } + pub fn design_geometry(&mut self, dx: i32, dy: i32, sizing: bool) -> Result<()> { + let keys = self.selected_keys()?; + self.design_edit(|f| { + // Ausgewählte Kinder eines ausgewählten Containers nicht doppelt verschieben. + fn edit(n: &mut FormNode, keys: &[Key], dx: i32, dy: i32, sizing: bool) -> Result<()> { + if keys.contains(&node_key(n)) && !matches!(n.class, Class::Timer | Class::Menu) { + for (name, delta) in [ + (if sizing { "WIDTH" } else { "LEFT" }, dx), + (if sizing { "HEIGHT" } else { "TOP" }, dy), + ] { + if delta == 0 { + continue; + } + let (_, spec) = forms::property(n.class, name).unwrap(); + ensure!(spec.writable, "{}.{} ist schreibgeschützt", n.name, name); + set(n, name, Value::Integer(integer(n, name) + delta))?; + } + if !sizing { + return Ok(()); + } + } + for c in &mut n.children { + edit(c, keys, dx, dy, sizing)?; + } + Ok(()) + } + edit(&mut f.root, &keys, dx, dy, sizing) + }) + } + fn design_resize(&mut self, dx: i32, dy: i32, edges: (i8, i8)) -> Result<()> { + let keys = self.selected_keys()?; + self.design_edit(|f| { + for key in keys { + let n = find_mut(&mut f.root, &key).ok_or_else(|| anyhow!("Control fehlt"))?; + if matches!(n.class, Class::Timer | Class::Menu) { + continue; + } + for (position, size, edge, delta) in [ + ("LEFT", "WIDTH", edges.0, dx), + ("TOP", "HEIGHT", edges.1, dy), + ] { + if edge == 0 || delta == 0 { + continue; + } + ensure!( + forms::property(n.class, size).unwrap().1.writable, + "{}.{} ist schreibgeschützt", + n.name, + size + ); + if edge < 0 { + set(n, position, Value::Integer(integer(n, position) + delta))?; + } + set( + n, + size, + Value::Integer(integer(n, size) + delta * i32::from(edge)), + )?; + } + } + Ok(()) + }) + } + pub fn design_property(&mut self, name: &str, input: &str) -> Result<()> { + let n = self.selected_node()?; + if name.eq_ignore_ascii_case("CTLNAME") || name.eq_ignore_ascii_case("FORMNAME") { + return self.design_rename(input); + } + let (p, s) = forms::property(n.class, name) + .ok_or_else(|| anyhow!("{}.{} unbekannt", n.name, name))?; + let edit = || -> Result { + ensure!(s.writable, "schreibgeschützt"); + Ok(match s.ty { + PropertyType::Integer => { + let v = input.parse::()?; + ensure!( + s.min.is_none_or(|min| v >= min) && s.max.is_none_or(|max| v <= max), + "außerhalb des Wertebereichs" + ); + Value::Integer(v) + } + PropertyType::Single => { + let v = input.parse::()?; + ensure!(v.is_finite(), "nicht endlich"); + Value::Single(v) + } + PropertyType::Boolean => { + Value::Boolean(match input.to_ascii_lowercase().as_str() { + "true" | "-1" | "1" => true, + "false" | "0" => false, + _ => bail!("Boolean erwartet: true/false"), + }) + } + PropertyType::String => Value::String(input.into()), + _ => bail!("nicht editierbarer Propertytyp"), + }) + }; + let v = edit().with_context(|| format!("{}.{}", n.name, s.name))?; + let (mut preview_model, _) = preview(self.design_form()?, self.size)?; + let object = self.design_form()?.catalog().find(&n.name).unwrap().0; + preview_model + .set_at(object, node_key(&n).1, p, v.clone()) + .with_context(|| format!("{}.{}", n.name, s.name))?; + let old = node_key(&n); + let doc = self.design_document()?; + let stable = self.project.design_ids(doc)?[&old]; + let mut changed = n.clone(); + changed.properties.insert(p, v.clone()); + if n.class == Class::Spin && s.name == "STYLE" { + set( + &mut changed, + "WIDTH", + Value::Integer(if input == "1" { 2 } else { 1 }), + )?; + set( + &mut changed, + "HEIGHT", + Value::Integer(if input == "1" { 1 } else { 2 }), + )?; + } + let new = node_key(&changed); + self.design_edit(|f| { + *find_mut(&mut f.root, &old).unwrap() = changed; + Ok(()) + }) + .with_context(|| format!("{}.{}", n.name, s.name))?; + if old != new { + self.project.rekey_design_id(doc, &old, new, stable); + } + Ok(()) + } + pub fn design_copy(&mut self, cut: bool) -> Result<()> { + let keys = self.selected_keys()?; + let mut copied = Vec::new(); + fn collect(n: &FormNode, keys: &[Key], out: &mut Vec) { + if n.class != Class::Form && keys.contains(&node_key(n)) { + out.push(n.clone()); + } else { + for c in &n.children { + collect(c, keys, out); + } + } + } + collect(&self.design_form()?.root, &keys, &mut copied); + ensure!(!copied.is_empty(), "Keine Controls ausgewählt"); + if cut { + self.design_delete()?; + } + self.designer.clipboard = copied; + Ok(()) + } + pub fn design_delete(&mut self) -> Result<()> { + let keys = self.selected_keys()?; + ensure!( + !keys.contains(&node_key(&self.design_form()?.root)), + "Formular über Remove File entfernen" + ); + let names: Vec<_> = nodes(&self.design_form()?.root) + .into_iter() + .filter(|n| keys.contains(&node_key(n))) + .flat_map(nodes) + .map(|n| n.name.clone()) + .collect(); + self.design_edit(|f| { + fn remove(n: &mut FormNode, keys: &[Key]) { + n.children.retain(|c| !keys.contains(&node_key(c))); + for c in &mut n.children { + remove(c, keys); + } + } + remove(&mut f.root, &keys); + Ok(()) + })?; + let id = self.design_document()?; + self.design_enter(id)?; + self.message=format!("Entfernt: {}. Vorhandener Code bleibt erhalten; zugehörige Handler sind jetzt ungebunden.",names.join(", ")); + self.open_dialog( + "Löschfolgen", + DialogKind::Message, + vec![Field::text("Hinweis", self.message.clone())], + ); + Ok(()) + } + pub fn design_paste(&mut self) -> Result<()> { + ensure!( + !self.designer.clipboard.is_empty(), + "Control-Zwischenablage leer" + ); + let parent = self.selected_node()?; + ensure!( + container(parent.class) || parent.class == Class::Menu, + "Zum Einfügen einen Container auswählen" + ); + let mut copies = self.designer.clipboard.clone(); + let mut names: BTreeSet<_> = nodes(&self.design_form()?.root) + .iter() + .map(|n| n.name.to_uppercase()) + .collect(); + let mut renamed = BTreeMap::new(); + fn rename( + n: &mut FormNode, + names: &mut BTreeSet, + renamed: &mut BTreeMap, + ) -> Result<()> { + let old = n.name.to_uppercase(); + let name = renamed + .entry(old.clone()) + .or_insert_with(|| { + let chosen = (1..) + .map(|i| format!("{old}Copy{i}")) + .find(|n| !names.contains(n)) + .unwrap(); + names.insert(chosen.clone()); + chosen + }) + .clone(); + n.name = name.clone(); + if let Some((p, _)) = forms::property(n.class, "CTLNAME") { + if n.properties.contains_key(&p) { + n.properties.insert(p, Value::String(name)); + } + } + for c in &mut n.children { + rename(c, names, renamed)?; + } + Ok(()) + } + for n in &mut copies { + rename(n, &mut names, &mut renamed)?; + } + let pasted: Vec<_> = copies.iter().map(node_key).collect(); + self.design_edit(|f| { + find_mut(&mut f.root, &node_key(&parent)) + .unwrap() + .children + .extend(copies); + Ok(()) + })?; + let doc = self.design_document()?; + let ids = self.project.design_ids(doc)?; + self.designer + .selections + .insert(doc, pasted.iter().map(|k| ids[k]).collect()); + Ok(()) + } + pub fn design_menu_move(&mut self, direction: &str) -> Result<()> { + let key = node_key(&self.selected_node()?); + self.design_edit(|f| { + fn change(n: &mut FormNode, key: &Key, d: &str) -> Result { + if let Some(i) = n.children.iter().position(|c| node_key(c) == *key) { + ensure!(n.children[i].class == Class::Menu, "Kein Menüelement"); + match d { + "up" => { + ensure!( + i > 0 && n.children[i - 1].class == Class::Menu, + "Kein Vorgänger" + ); + n.children.swap(i, i - 1); + } + "down" => { + ensure!( + i + 1 < n.children.len() && n.children[i + 1].class == Class::Menu, + "Kein Nachfolger" + ); + n.children.swap(i, i + 1); + } + "in" => { + ensure!( + i > 0 && n.children[i - 1].class == Class::Menu, + "Kein Menüvorgänger" + ); + let c = n.children.remove(i); + n.children[i - 1].children.push(c); + } + _ => bail!("Keine äußere Menüebene"), + } + return Ok(true); + } + for i in 0..n.children.len() { + if d == "out" { + if let Some(j) = n.children[i] + .children + .iter() + .position(|c| node_key(c) == *key) + { + let c = n.children[i].children.remove(j); + n.children.insert(i + 1, c); + return Ok(true); + } + } + if change(&mut n.children[i], key, d)? { + return Ok(true); + } + } + Ok(false) + } + ensure!(change(&mut f.root, &key, direction)?, "Menüelement fehlt"); + Ok(()) + }) + } + pub fn design_rename(&mut self, new: &str) -> Result<()> { + self.design_rename_with(new, None) + } + fn design_rename_with(&mut self, new: &str, extra: Option) -> Result<()> { + let selected = self.selected_node()?; + let old = selected.name.to_uppercase(); + if old == new.to_uppercase() { + return Ok(()); + } + // Benutzt dieselbe Namensprüfung wie die Prozedurerzeugung. + tb_frontend::editing::new_procedure("", 0, new, false).map_err(|e| anyhow!(e))?; + let doc = self.design_document()?; + let ids = self.project.design_ids(doc)?.clone(); + let input = self.project.sources()?; + let mut compiler = tb_vm::project::ProjectCompiler::default(); + let compiled = input.compile(&mut compiler, "RENAME").map_err(|d| { + anyhow!( + "Umbenennen erfordert eindeutig auflösbare Quellen: {}", + d.iter() + .map(ToString::to_string) + .collect::>() + .join("; ") + ) + })?; + let mut catalog = forms::FormCatalog::default(); + for f in &input.forms { + catalog.append(&f.catalog()); + } + ensure!( + !catalog + .objects + .iter() + .any(|o| o.name.eq_ignore_ascii_case(new)), + "Name {new} bereits vorhanden" + ); + let form_name = self.design_form()?.root.name.clone(); + let target = if selected.class == Class::Form { + catalog.find(&old) + } else { + catalog.find_in(&old, &form_name) + } + .ok_or_else(|| anyhow!("Objektbindung fehlt"))? + .0; + let mut changes: BTreeMap = BTreeMap::new(); + let mut edits: BTreeMap> = BTreeMap::new(); + let mut handlers = BTreeMap::new(); + let mut references = Vec::new(); + let mut procedures = Vec::new(); + let source_files = compiled.sources; + let bound = compiler.bound_form_references().map_err(|d| { + anyhow!( + "Unklare Bindungen: {}", + d.iter() + .map(ToString::to_string) + .collect::>() + .join("; ") + ) + })?; + procedures.extend(bound.procedures); + for (pos, name, object) in &bound.objects { + if *object != target { + continue; + } + if *name == old { + references.push((*pos, name.clone(), new.to_owned())); + } else if let Some(event) = name.strip_prefix(&format!("{old}_")) { + let replacement = format!("{new}_{event}"); + handlers.insert(name.clone(), replacement.clone()); + references.push((*pos, name.clone(), replacement)); + } + } + for (pos, name) in procedures { + if let Some(replacement) = handlers.get(&name) { + references.push((pos, name, replacement.clone())); + } + } + for (pos, name, replacement) in references { + let path = &source_files[pos.source as usize].path; + let id = self + .project + .find_document(std::path::Path::new(path)) + .ok_or_else(|| anyhow!("Betroffene Include-Datei nicht geöffnet: {path}"))?; + let d = self.project.document(id)?; + let first = match d.content() { + Content::Form(f) => frm::read_text(path, &frm::write_text(f))?.code_line(), + _ => 1, + }; + let line = pos + .line + .checked_sub(first) + .ok_or_else(|| anyhow!("Referenz außerhalb des Codebereichs"))? + + 1; + let lexed = lexer::lex(d.code()); + let t = lexed + .tokens + .iter() + .find(|t| { + t.pos.line == line + && t.pos.column >= pos.column + && matches!(&t.kind,TokenKind::Ident{name:n,..} if n.split(['.','!']).any(|part|part==name)) + }) + .ok_or_else(|| anyhow!("{path}:{line}: {name} nicht sicher lokalisierbar"))?; + let token_start = token_offset(d.code(), t.pos.line, t.pos.column)?; + let TokenKind::Ident { + name: path_name, .. + } = &t.kind + else { + unreachable!() + }; + let mut component = 0; + for part in path_name.split(['.', '!']) { + if part == name { + break; + } + component += part.len() + 1; + } + let start = token_start + component; + ensure!( + d.code() + .get(start..start + name.len()) + .is_some_and(|s| s.eq_ignore_ascii_case(&name)), + "Referenz nicht bytegenau auflösbar" + ); + edits + .entry(id) + .or_default() + .insert(start, (start + name.len(), replacement)); + } + for (id, replacements) in edits { + let mut content = self.project.document(id)?.content().clone(); + for (start, (end, replacement)) in replacements.into_iter().rev() { + content.code_mut().replace_range(start..end, &replacement); + } + changes.insert(id, content); + } + let content = changes + .entry(doc) + .or_insert_with(|| self.project.document(doc).unwrap().content().clone()); + let Content::Form(f) = content else { + unreachable!() + }; + fn rename(n: &mut FormNode, old: &str, new: &str) { + if n.name.eq_ignore_ascii_case(old) { + n.name = new.into(); + let property = if n.class == Class::Form { + "FORMNAME" + } else { + "CTLNAME" + }; + if let Some((p, _)) = forms::property(n.class, property) { + if n.properties.contains_key(&p) { + n.properties.insert(p, Value::String(new.into())); + } + } + } + for c in &mut n.children { + rename(c, old, new); + } + } + if let Some(extra) = extra { + f.root = extra.root; + } + rename(&mut f.root, &old, new); + validate(f)?; + let mut loader = self.project.loader()?; + for (id, c) in &changes { + loader + .insert(self.project.document(*id)?.source_path(), c.clone()) + .map_err(|e| anyhow!(e))?; + } + let candidate = loader + .load_manifest(self.project.manifest().clone()) + .map_err(|e| anyhow!(e))?; + let mut candidate_compiler = tb_vm::project::ProjectCompiler::default(); + let candidate_compiled = candidate + .compile(&mut candidate_compiler, "RENAME") + .map_err(|d| { + anyhow!( + "Namenskonflikt; nichts geändert: {}", + d.iter() + .map(ToString::to_string) + .collect::>() + .join("; ") + ) + })?; + // Dieselben vollständig aufgelösten Importkontexte vor und nach der Änderung. + let candidate_bound = candidate_compiler.bound_form_references().map_err(|d| { + anyhow!( + "Unklare neue Bindungen: {}", + d.iter() + .map(ToString::to_string) + .collect::>() + .join("; ") + ) + })?; + let bindings = |references: &[(tb_frontend::SourcePos, String, u16)], + files: &[tb_frontend::source::SourceFile], + rename_old: bool| { + let mut result = Vec::new(); + for (p, n, o) in references { + let mut n = n.clone(); + if rename_old && *o == target { + if n == old { + n = new.to_uppercase(); + } else if let Some(r) = handlers.get(&n) { + n = r.to_uppercase(); + } + } + result.push((files[p.source as usize].path.clone(), n, *o)); + } + result.sort(); + result + }; + ensure!( + bindings(&bound.objects, &source_files, true) + == bindings(&candidate_bound.objects, &candidate_compiled.sources, false), + "Unklare oder verdeckte Objektverweise; nichts geändert" + ); + self.project + .commit_transaction(changes.into_iter().collect())?; + self.project.rename_design_ids(doc, &old, new, &ids); + self.designer.value = None; + Ok(()) + } + pub fn design_event(&mut self, object: DesignId, event: &str) -> Result<()> { + let doc = self.design_document()?; + let n = self + .design_objects()? + .into_iter() + .find(|(id, _)| *id == object) + .ok_or_else(|| anyhow!("Objekt fehlt"))? + .1; + let event = event.to_uppercase(); + ensure!( + forms::events(n.class).contains(&event.as_str()), + "{}.{} ist kein Ereignis", + n.name, + event + ); + let prefix = if n.class == Class::Form { + "Form" + } else { + &n.name + }; + let name = format!("{prefix}_{event}"); + let input = self.project.sources()?; + let mut catalog = forms::FormCatalog::default(); + for f in &input.forms { + catalog.append(&f.catalog()); + } + let target_object = if n.class == Class::Form { + catalog.find(&n.name) + } else { + catalog.find_in(&n.name, &self.design_form()?.root.name) + } + .ok_or_else(|| anyhow!("Objektbindung fehlt"))? + .0; + let mut found = None; + for unit in &input.units { + let mut files = Vec::new(); + let (module, diags) = unit.parse(0, &mut files); + ensure!( + diags.is_empty(), + "Unvollständiger Quelltext vor Ereigniserzeugung" + ); + let bound = tb_frontend::sema::bound_form_references(&module, &catalog); + let exports = tb_frontend::sema::export_declarations(&module, &[]); + for p in module.procs.iter().filter(|p| { + p.sig.name.rsplit_once('_').is_some_and(|(_, e)| e == event) + && bound.objects.iter().any(|(pos, name, object)| { + *pos == p.pos && *name == p.sig.name && *object == target_object + }) + }) { + let sig = exports + .iter() + .find_map(|s| match s { + tb_frontend::ast::Stmt::Declare { sig, .. } if sig.name == p.sig.name => { + Some(sig) + } + _ => None, + }) + .unwrap(); + ensure!(found.is_none(), "Mehrdeutige Ereignisprozedur {name}"); + let target = self + .project + .find_document(std::path::Path::new(&files[p.pos.source as usize].path)) + .ok_or_else(|| anyhow!("Ereignisdatei fehlt"))?; + let params = event_parameters(&n, &event); + ensure!( + sig.kind == tb_frontend::ast::ProcKind::Sub + && sig.params.len() == params.len() + && sig + .params + .iter() + .zip(¶ms) + .all( + |(actual, (name, ty))| actual.name.eq_ignore_ascii_case(name) + && actual + .as_type + .as_ref() + .map(tb_frontend::editing::type_text) + .or_else(|| actual.suffix.map(|s| match s { + lexer::Suffix::Integer => "INTEGER", + lexer::Suffix::Single => "SINGLE", + _ => "", + } + .into())) + .is_some_and(|t| t == *ty) + ), + "{name}: vorhandene Ereignissignatur stimmt nicht" + ); + let first = match self.project.document(target)?.content() { + Content::Form(f) => frm::read_text("Event", &frm::write_text(f))?.code_line(), + _ => 1, + }; + found = Some(( + target, + crate::editor::line_offset( + self.project.document(target)?.code(), + p.pos.line - first + 1, + ), + )); + } + } + let (target, at) = if let Some(found) = found { + found + } else { + let code = self.project.document(doc)?.code(); + let mut addition = self.new_procedure_text(doc, code.len(), &name, false, true)?; + let params = event_parameters(&n, &event) + .iter() + .map(|(name, ty)| format!("{name} AS {ty}")) + .collect::>() + .join(", "); + // BASIC-Parameter sind BYREF; derselbe Prozedurrumpf wie New Sub. + addition = addition.replacen(" ()", &format!(" ({params})"), 1); + let at = code.len(); + self.project.replace_text(doc, at..at, &addition)?; + (doc, at + 1) + }; + self.designer.document = Some(doc); + self.designer.selections.insert(doc, vec![object]); + self.show_document(target, false)?; + let v = self.editor_view()?; + self.project.view_mut(v)?.cursor = at; + self.editor_scroll()?; + Ok(()) + } + pub(crate) fn designer_command(&mut self, command: Command) -> Result { + use Command::*; + match command { + Grid => self.designer.grid = !self.designer.grid, + Toolbox | Palette | MenuDesign => { + self.design_document()?; + self.show_tool(match command { + Toolbox => WindowKind::Toolbox, + Palette => WindowKind::Palette, + _ => WindowKind::MenuDesign, + }); + } + Tool(name) => { + let (_, class, style) = tools() + .into_iter() + .find(|(n, _, _)| n.eq_ignore_ascii_case(name)) + .ok_or_else(|| anyhow!("Unbekanntes Werkzeug"))?; + let doc = self.design_document()?; + self.show_document(doc, true)?; + self.designer.tool = Some((class, style)); + self.value_focus = false; + } + Events => { + let objects = self.design_objects()?; + let selected = self.selected_keys()?.last().cloned(); + let at = objects + .iter() + .position(|(_, n)| Some(node_key(n)) == selected) + .unwrap_or(0); + self.open_dialog( + "Event Procedures · Objekt", + DialogKind::DesignObject(objects.iter().map(|(id, _)| *id).collect()), + vec![Field::choice( + "Objekt", + objects + .iter() + .map(|(_, n)| format!("{} {:?}", n.name, node_key(n).1)) + .collect(), + at, + )], + ); + } + Cut | Copy | Paste | Clear if self.mode == Mode::Designer => match command { + Cut => self.design_copy(true)?, + Copy => self.design_copy(false)?, + Paste => self.design_paste()?, + _ => self.design_delete()?, + }, + Undo if self.mode == Mode::Designer => { + let doc = self.design_document()?; + self.project.undo(doc)?; + self.design_enter(doc)?; + } + Form if self.designer.document.is_some() && self.mode == Mode::Environment => { + self.show_document(self.designer.document.unwrap(), true)?; + } + Code if self.mode == Mode::Designer => { + let doc = self.design_document()?; + self.show_document(doc, false)?; + } + _ => return Ok(false), + } + Ok(true) + } + pub(crate) fn design_value_focus(&mut self) -> Result<()> { + let node = self.selected_node()?; + let specs = forms::properties(node.class); + self.designer.property = self.designer.property.min(specs.len() - 1); + let name = specs[self.designer.property].name; + let v = if matches!(name, "CTLNAME" | "FORMNAME") { + node.name.clone() + } else { + text(&value(&node, name).unwrap()) + }; + self.designer.value = Some(Field::text(name, v)); + self.properties = true; + self.value_focus = true; + Ok(()) + } + pub(crate) fn design_action(&mut self, action: &str) -> Result<()> { + match action { + "property" => { + let n = self.selected_node()?; + let specs = forms::properties(n.class); + self.open_dialog( + "Properties", + DialogKind::DesignProperty, + vec![Field::choice( + "Property", + specs.iter().map(|p| p.name.into()).collect(), + self.designer.property.min(specs.len() - 1), + )], + ); + } + "value" => self.design_value_focus()?, + "palette" => self.open_dialog( + "Color Palette", + DialogKind::DesignPalette, + vec![ + Field::choice("ForeColor", (0..16).map(|n| n.to_string()).collect(), 0), + Field::choice("BackColor", (0..16).map(|n| n.to_string()).collect(), 7), + Field::toggle("Auf Formular auftragen", false), + ], + ), + "insert" => { + let objects = self.design_objects()?; + let selected = self.selected_node()?; + let parent = if selected.class == Class::Menu { + objects + .iter() + .find(|(_, n)| node_key(n) == node_key(&selected)) + .unwrap() + .0 + } else { + objects[0].0 + }; + self.design_place(Class::Menu, 0, parent, Rect::default())?; + } + "delete" => self.design_delete()?, + "up" | "down" | "in" | "out" => self.design_menu_move(action)?, + "menu" => { + let n = self.selected_node()?; + ensure!(n.class == Class::Menu, "Menüelement auswählen"); + let shortcuts = forms::menu_shortcuts(); + let current = text(&value(&n, "SHORTCUT").unwrap()); + let at = shortcuts + .iter() + .position(|s| s.eq_ignore_ascii_case(¤t.replace(' ', ""))) + .unwrap_or(0); + self.open_dialog( + "Menu Design", + DialogKind::DesignMenu(node_key(&n)), + vec![ + Field::text("CtlName", n.name.clone()), + Field::text("Caption", text(&value(&n, "CAPTION").unwrap())), + Field::text("Tag", text(&value(&n, "TAG").unwrap())), + Field::text( + "Index (leer: skalar)", + node_key(&n).1.map(|i| i.to_string()).unwrap_or_default(), + ), + Field::toggle( + "Checked", + value(&n, "CHECKED") == Some(Value::Boolean(true)), + ), + Field::toggle( + "Enabled", + value(&n, "ENABLED") == Some(Value::Boolean(true)), + ), + Field::toggle( + "Visible", + value(&n, "VISIBLE") == Some(Value::Boolean(true)), + ), + Field::toggle( + "Separator", + value(&n, "SEPARATOR") == Some(Value::Boolean(true)), + ), + Field::choice("Shortcut", shortcuts, at), + ], + ); + } + _ => bail!("Unbekannte Designeraktion"), + } + Ok(()) + } + pub(crate) fn design_submit(&mut self, d: &mut Dialog) -> Result { + match &d.kind { + DialogKind::DesignProperty => { + self.designer.property = d.fields[0].index(); + self.design_value_focus()?; + } + DialogKind::DesignObject(ids) => { + let id = ids[d.fields[0].index()]; + let n = self + .design_objects()? + .into_iter() + .find(|(i, _)| *i == id) + .unwrap() + .1; + self.open_dialog( + "Event Procedures · Ereignis", + DialogKind::DesignEvent(id), + vec![Field::choice( + "Ereignis", + forms::events(n.class) + .iter() + .map(|s| s.to_string()) + .collect(), + 0, + )], + ); + } + DialogKind::DesignEvent(id) => self.design_event(*id, &d.fields[0].string())?, + DialogKind::DesignPalette => { + let keys = if d.fields[2].flag() { + vec![node_key(&self.design_form()?.root)] + } else { + self.selected_keys()? + }; + let fg = d.fields[0].index() as i32; + let bg = d.fields[1].index() as i32; + self.design_edit(|f| { + for key in keys { + let n = find_mut(&mut f.root, &key).unwrap(); + for (name, v) in [("FORECOLOR", fg), ("BACKCOLOR", bg)] { + if forms::property(n.class, name).is_some() { + set(n, name, Value::Integer(v))?; + } + } + } + Ok(()) + })?; + } + DialogKind::DesignMenu(key) => { + let key = key.clone(); + let new = d.fields[0].string(); + let mut candidate = self.design_form()?.clone(); + let n = find_mut(&mut candidate.root, &key) + .ok_or_else(|| anyhow!("Menüelement fehlt"))?; + for (name, field) in [("CAPTION", 1), ("TAG", 2), ("SHORTCUT", 8)] { + set(n, name, Value::String(d.fields[field].string()))?; + } + for (name, field) in [ + ("CHECKED", 4), + ("ENABLED", 5), + ("VISIBLE", 6), + ("SEPARATOR", 7), + ] { + set(n, name, Value::Boolean(d.fields[field].flag()))?; + } + let p = forms::property(Class::Menu, "INDEX").unwrap().0; + if d.fields[3].string().is_empty() { + n.properties.remove(&p); + } else { + n.properties.insert( + p, + Value::Integer( + d.fields[3] + .string() + .parse() + .context("Menu.Index: Integer erwartet")?, + ), + ); + } + let final_key = node_key(n); + let updated = n.clone(); + validate(&candidate)?; + // Eine Namensänderung und die übrigen Felder werden zusammen validiert. + // Die Rename-Transaktion muss vor jedem Commit alle Kandidaten kennen. + self.design_menu_commit(&key, &new, updated, final_key)?; + } + _ => return Ok(false), + } + Ok(true) + } + pub(crate) fn design_key(&mut self, key: KeyEvent) -> Result { + if self.mode != Mode::Designer { + return Ok(false); + } + if self.value_focus { + if key.code == K::Esc { + self.value_focus = false; + self.designer.value = None; + return Ok(true); + } + if key.code == K::Enter { + let field = self + .designer + .value + .clone() + .ok_or_else(|| anyhow!("Keine Property ausgewählt"))?; + self.design_property(&field.label, &field.string())?; + self.value_focus = false; + return Ok(true); + } + if let Some(v) = &mut self.designer.value { + v.key(key); + } + return Ok(true); + } + match self.active_window().map(|w| w.kind) { + Some(WindowKind::Palette) if key.code == K::Enter => { + self.design_action("palette")?; + return Ok(true); + } + Some(WindowKind::Toolbox) => { + let list = tools(); + let at = self + .designer + .tool + .and_then(|t| list.iter().position(|(_, c, s)| (*c, *s) == t)) + .unwrap_or(0); + match key.code { + K::Up | K::Down => { + let i = (at + + list.len() + + if key.code == K::Down { + 1 + } else { + list.len() - 1 + }) + % list.len(); + self.designer.tool = Some((list[i].1, list[i].2)); + } + K::Enter => { + self.design_tool_double(at)?; + } + _ => return Ok(false), + } + return Ok(true); + } + Some(WindowKind::MenuDesign) => { + let control = key.modifiers.contains(M::CONTROL); + match key.code { + K::Insert => self.design_action("insert")?, + K::Delete => self.design_action("delete")?, + K::Enter => self.design_action("menu")?, + K::Left if control => self.design_action("out")?, + K::Right if control => self.design_action("in")?, + K::Up if control => self.design_action("up")?, + K::Down if control => self.design_action("down")?, + K::Up | K::Down => self.design_tab(key.code == K::Down, true)?, + _ => return Ok(false), + } + return Ok(true); + } + _ => {} + } + match key.code { + K::Tab | K::BackTab => self.design_tab( + key.code == K::Tab && !key.modifiers.contains(M::SHIFT), + false, + )?, + K::PageUp | K::PageDown => { + let n = forms::properties(self.selected_node()?.class).len(); + self.designer.property = + (self.designer.property + n + if key.code == K::PageDown { 1 } else { n - 1 }) + % n; + } + K::Left | K::Right | K::Up | K::Down => { + let step = if key.modifiers.contains(M::CONTROL) { + 5 + } else { + 1 + }; + let (dx, dy) = match key.code { + K::Left => (-step, 0), + K::Right => (step, 0), + K::Up => (0, -step), + _ => (0, step), + }; + self.design_geometry(dx, dy, key.modifiers.contains(M::SHIFT))?; + } + K::Delete => self.design_delete()?, + K::Esc => self.designer.tool = None, + _ => return Ok(false), + } + Ok(true) + } + fn design_tab(&mut self, forward: bool, menus: bool) -> Result<()> { + let mut objects = self.design_objects()?; + objects.retain(|(_, n)| { + if menus { + n.class == Class::Menu + } else { + !matches!(n.class, Class::Form | Class::Menu) + } + }); + if objects.is_empty() { + return Ok(()); + } + if !menus { + objects.sort_by_key(|(_, n)| integer(n, "TABINDEX")); + } + let selected = self.selected_keys()?.last().cloned(); + let i = objects + .iter() + .position(|(_, n)| Some(node_key(n)) == selected); + let at = i.map_or(if forward { 0 } else { objects.len() - 1 }, |i| { + (i + objects.len() + if forward { 1 } else { objects.len() - 1 }) % objects.len() + }); + self.design_select(objects[at].0, false) + } + fn design_tool_double(&mut self, index: usize) -> Result<()> { + let (_, class, style) = tools()[index]; + let objects = self.design_objects()?; + let selected = self.selected_node()?; + let parent = objects + .iter() + .find(|(_, n)| node_key(n) == node_key(&selected) && container(n.class)) + .unwrap_or(&objects[0]) + .0; + self.design_place( + class, + style, + parent, + Rect::new( + 1, + 1, + if class == Class::VScrollBar { 1 } else { 8 }, + if class == Class::VScrollBar { 5 } else { 1 }, + ), + )?; + self.designer.tool = None; + Ok(()) + } + pub(crate) fn design_mouse(&mut self, m: MouseEvent) -> Result { + if self.mode != Mode::Designer || self.dialog.is_some() || self.menu.is_some() { + return Ok(false); + } + if let Some((property, color)) = self.designer.paint { + if m.kind == MouseEventKind::Up(MouseButton::Left) { + self.designer.paint = None; + let id = self + .designer + .objects + .iter() + .rev() + .find(|(_, r)| r.contains((m.column, m.row).into())) + .map(|(id, _)| *id) + .ok_or_else(|| anyhow!("Farbe auf die Designfläche ziehen"))?; + let n = self + .design_objects()? + .into_iter() + .find(|(i, _)| *i == id) + .unwrap() + .1; + ensure!( + forms::property(n.class, property).is_some(), + "{}.{} nicht vorhanden", + n.name, + property + ); + self.design_edit(|f| { + set( + find_mut(&mut f.root, &node_key(&n)).unwrap(), + property, + Value::Integer(color as i32), + ) + })?; + } + return Ok(true); + } + if m.kind == MouseEventKind::Down(MouseButton::Left) { + if let Some((_, Hit::DesignPaint(property, color))) = self + .hits + .iter() + .rev() + .find(|(r, _)| r.contains((m.column, m.row).into())) + { + self.designer.paint = Some((*property, *color)); + return Ok(true); + } + } + if m.kind == MouseEventKind::Down(MouseButton::Left) { + if let Some((_, Hit::DesignTool(index))) = self + .hits + .iter() + .rev() + .find(|(r, _)| r.contains((m.column, m.row).into())) + { + let index = *index; + let double = self + .designer + .last_tool_click + .is_some_and(|(i, t)| i == index && t.elapsed().as_millis() < 500); + self.designer.last_tool_click = Some((index, std::time::Instant::now())); + let (_, class, style) = tools()[index]; + self.designer.tool = Some((class, style)); + if double { + self.designer.last_tool_click = None; + self.design_tool_double(index)?; + } + return Ok(true); + } + } + if self.designer.drag.is_none() { + if let Some((_, hit)) = self + .hits + .iter() + .rev() + .find(|(r, _)| r.contains((m.column, m.row).into())) + { + match hit { + Hit::Window(id) + if self + .windows + .iter() + .any(|w| w.id == *id && matches!(w.kind, WindowKind::Code(_))) => {} + _ => return Ok(false), + } + } + } + let inside = self.designer.viewport.contains((m.column, m.row).into()); + if !inside && self.designer.drag.is_none() { + return Ok(false); + } + match m.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(doc) = self.designer.document { + if let Some(w)=self.windows.iter().find(|w|matches!(w.kind,WindowKind::Code(v) if self.project.view(v).is_ok_and(|v|v.document()==doc))) {self.active=w.id;} + } + let hit = self + .designer + .objects + .iter() + .rev() + .find(|(_, r)| r.contains((m.column, m.row).into())) + .copied(); + if let Some((id, r)) = hit { + let selected = self + .designer + .selections + .get(&self.design_document()?) + .is_some_and(|s| s.contains(&id)); + let resize = if selected && self.designer.tool.is_none() { + handle_at(r, m.column, m.row) + } else { + None + }; + if !selected || m.modifiers.contains(M::CONTROL) { + self.design_select(id, m.modifiers.contains(M::CONTROL))?; + } + self.designer.drag = Some((m.column, m.row, resize)); + } else if self.designer.tool.is_some() { + self.designer.drag = Some((m.column, m.row, None)); + } + self.value_focus = false; + } + MouseEventKind::Up(MouseButton::Left) => { + if let Some((x, y, handle)) = self.designer.drag.take() { + if let Some((class, style)) = self.designer.tool.take() { + let objects = self.design_objects()?; + let parent = self + .designer + .objects + .iter() + .rev() + .find(|(id, r)| { + r.contains((x, y).into()) + && objects.iter().any(|(i, n)| i == id && container(n.class)) + }) + .copied() + .ok_or_else(|| anyhow!("Innerhalb eines Containers aufziehen"))?; + let left = x + .min(m.column) + .checked_sub(parent.1.x) + .ok_or_else(|| anyhow!("Außerhalb des Containers"))?; + let top = y + .min(m.row) + .checked_sub(parent.1.y) + .ok_or_else(|| anyhow!("Außerhalb des Containers"))?; + self.design_place( + class, + style, + parent.0, + Rect::new(left, top, x.abs_diff(m.column) + 1, y.abs_diff(m.row) + 1), + )?; + } else { + let dx = m.column as i32 - x as i32; + let dy = m.row as i32 - y as i32; + if dx != 0 || dy != 0 { + if let Some(edges) = handle { + self.design_resize(dx, dy, edges)?; + } else { + self.design_geometry(dx, dy, false)?; + } + } + } + } + } + MouseEventKind::Drag(MouseButton::Left) => {} + _ => return Ok(false), + } + Ok(true) + } + fn design_menu_commit( + &mut self, + key: &Key, + new: &str, + updated: FormNode, + final_key: Key, + ) -> Result<()> { + let doc = self.design_document()?; + let stable = self.project.design_ids(doc)?[key]; + let mut candidate = self.design_form()?.clone(); + *find_mut(&mut candidate.root, key).unwrap() = updated; + if !key.0.eq_ignore_ascii_case(new) { + self.design_rename_with(new, Some(candidate))?; + } else { + self.design_edit(|f| { + f.root = candidate.root; + Ok(()) + })?; + } + self.project + .rekey_design_id(doc, key, (new.to_uppercase(), final_key.1), stable); + Ok(()) + } + pub(crate) fn design_render(&mut self, f: &mut Frame, area: Rect) { + self.designer.viewport = area; + self.designer.objects.clear(); + let render = || -> Result<_> { + let form = self.design_form()?.clone(); + let (model, screen) = preview(&form, (area.width, area.height))?; + Ok((form, model, screen)) + }; + let Ok((form, model, screen)) = render() else { + return; + }; + f.render_widget(tb_ui::screen::ScreenWidget(&screen), area); + let Ok(objects) = self.design_objects() else { + return; + }; + let catalog = form.catalog(); + let mut timers = 0; + for (id, n) in objects { + if n.class == Class::Menu { + continue; + } + let object = catalog.find(&n.name).unwrap().0; + let Some((x, y, w, h)) = model.rect((object, node_key(&n).1)) else { + continue; + }; + let r = if n.class == Class::Timer { + let r = Rect::new(area.x + timers * 4, area.bottom().saturating_sub(1), 3, 1); + timers += 1; + f.render_widget( + Paragraph::new("[T]").style(Style::default().fg(crate::render::dos(14))), + r.intersection(area), + ); + r + } else { + Rect::new( + area.x + x.saturating_sub(1) as u16, + area.y + y.saturating_sub(1) as u16, + w as u16, + h as u16, + ) + }; + let r = r.intersection(area); + if r.is_empty() { + continue; + } + if self.designer.grid && n.class == Class::Form { + for y in r.y..r.bottom() { + for x in r.x..r.right() { + let cell = &mut f.buffer_mut()[(x, y)]; + if cell.symbol() == " " { + cell.set_symbol("·"); + } + } + } + } + self.designer.objects.push((id, r)); + } + let selection = self + .design_document() + .ok() + .and_then(|id| self.designer.selections.get(&id)) + .cloned() + .unwrap_or_default(); + for (id, r) in &self.designer.objects { + if selection.contains(id) { + for (x, y) in [ + (r.x, r.y), + (r.right() - 1, r.y), + (r.x, r.bottom() - 1), + (r.right() - 1, r.bottom() - 1), + (r.x + r.width / 2, r.y), + (r.x + r.width / 2, r.bottom() - 1), + (r.x, r.y + r.height / 2), + (r.right() - 1, r.y + r.height / 2), + ] { + f.render_widget( + Paragraph::new("■").style(Style::default().fg(crate::render::dos(14))), + Rect::new(x, y, 1, 1), + ); + } + } + } + } + pub(crate) fn design_tool_render(&mut self, f: &mut Frame, kind: WindowKind, area: Rect) { + match kind { + WindowKind::Toolbox => { + for (i, (name, class, style)) in + tools().iter().enumerate().take(area.height as usize) + { + let r = Rect::new(area.x, area.y + i as u16, area.width, 1); + f.render_widget( + Paragraph::new(format!( + "{} {name}", + if self.designer.tool == Some((*class, *style)) { + ">" + } else { + " " + } + )), + r, + ); + self.hits.push((r, Hit::DesignTool(i))); + } + } + WindowKind::Palette => { + for (row, property) in [(0, "FORECOLOR"), (2, "BACKCOLOR")] { + f.render_widget( + Paragraph::new(property), + Rect::new(area.x, area.y + row, area.width, 1).intersection(area), + ); + for n in 0..16u16 { + let r = + Rect::new(area.x + n * 2, area.y + row + 1, 2, 1).intersection(area); + if !r.is_empty() { + f.render_widget( + Paragraph::new(format!("{n:X} ")).style( + Style::default() + .bg(crate::render::dos(n as u8)) + .fg(crate::render::dos(if n < 8 { 15 } else { 0 })), + ), + r, + ); + self.hits.push((r, Hit::DesignPaint(property, n as u8))); + } + } + } + let r = Rect::new(area.x, area.y + 4, area.width, 1).intersection(area); + f.render_widget(Paragraph::new("Enter: Fore/Back auftragen"), r); + self.hits.push((r, Hit::DesignAction("palette"))); + } + WindowKind::MenuDesign => { + let labels = [ + ("+", "insert"), + ("Edit", "menu"), + ("Del", "delete"), + ("↑", "up"), + ("↓", "down"), + ("→", "in"), + ("←", "out"), + ]; + let mut x = area.x; + for (label, action) in labels { + let r = Rect::new(x, area.y, label.len() as u16 + 1, 1).intersection(area); + f.render_widget(Paragraph::new(label), r); + self.hits.push((r, Hit::DesignAction(action))); + x += r.width; + } + if let Ok(objects) = self.design_objects() { + let selected = self + .design_document() + .ok() + .and_then(|d| self.designer.selections.get(&d)) + .cloned() + .unwrap_or_default(); + let menus: Vec<_> = objects + .into_iter() + .filter(|(_, n)| n.class == Class::Menu) + .collect(); + let current = menus + .iter() + .position(|(id, _)| selected.contains(id)) + .unwrap_or(0); + let start = current.saturating_sub(area.height.saturating_sub(3) as usize); + for (row, (id, n)) in menus + .iter() + .skip(start) + .take(area.height.saturating_sub(1) as usize) + .enumerate() + { + fn depth(n: &FormNode, key: &Key) -> Option { + if node_key(n) == *key { + Some(0) + } else { + n.children.iter().find_map(|c| depth(c, key).map(|d| d + 1)) + } + } + let depth = self + .design_form() + .ok() + .and_then(|f| depth(&f.root, &node_key(n))) + .unwrap_or(1); + let r = Rect::new(area.x, area.y + 1 + row as u16, area.width, 1); + f.render_widget( + Paragraph::new(format!( + "{}{} {}{}", + " ".repeat(depth.saturating_sub(1) * 2), + text(&value(n, "CAPTION").unwrap()), + n.name, + node_key(n).1.map(|i| format!("({i})")).unwrap_or_default() + )) + .style(if selected.contains(id) { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }), + r, + ); + self.hits.push((r, Hit::DesignObject(*id))); + } + } + } + _ => {} + } + } + pub(crate) fn design_bar(&mut self) -> (String, String) { + let Ok(n) = self.selected_node() else { + return ("Properties: kein Control ausgewählt".into(), String::new()); + }; + let specs = forms::properties(n.class); + self.designer.property = self.designer.property.min(specs.len() - 1); + let name = specs[self.designer.property].name; + let v = self + .designer + .value + .as_ref() + .filter(|_| self.value_focus) + .map(Field::string) + .unwrap_or_else(|| { + if matches!(name, "CTLNAME" | "FORMNAME") { + n.name.clone() + } else { + text(&value(&n, name).unwrap()) + } + }); + ( + format!( + "Property: {} [{}]↓ Value: [{}]{}", + n.name, + name, + v, + if self.value_focus { " *" } else { "" } + ), + format!( + "{},{} │ {}×{}", + integer(&n, "LEFT"), + integer(&n, "TOP"), + integer(&n, "WIDTH"), + integer(&n, "HEIGHT") + ), + ) + } +} +fn token_offset(code: &str, line: u32, col: u32) -> Result { + let start = crate::editor::line_offset(code, line); + let offset = code[start..] + .char_indices() + .nth(col.saturating_sub(1) as usize) + .map(|(i, _)| i) + .ok_or_else(|| anyhow!("Ungültige Quellposition"))?; + Ok(start + offset) +} +fn event_parameters(n: &FormNode, event: &str) -> Vec<(String, String)> { + let mut params = Vec::new(); + if node_key(n).1.is_some() { + params.push(("Index".into(), "INTEGER".into())); + } + for (name, ty) in forms::event_params(event).unwrap_or_default() { + params.push(( + (*name).into(), + match ty { + forms::EventParamType::Integer => "INTEGER", + forms::EventParamType::Single => "SINGLE", + forms::EventParamType::Control => "CONTROL", + } + .into(), + )); + } + params +} + +fn handle_at(r: Rect, x: u16, y: u16) -> Option<(i8, i8)> { + let columns = [(r.x, -1), (r.right() - 1, 1), (r.x + r.width / 2, 0)]; + let rows = [(r.y, -1), (r.bottom() - 1, 1), (r.y + r.height / 2, 0)]; + for (col, horizontal) in columns { + for (row, vertical) in rows { + if (horizontal != 0 || vertical != 0) && x == col && y == row { + return Some(( + if r.width == 1 { 0 } else { horizontal }, + if r.height == 1 { 0 } else { vertical }, + )); + } + } + } + None +} diff --git a/crates/tb-ide/src/documents.rs b/crates/tb-ide/src/documents.rs index fc68ecd..5e587f4 100644 --- a/crates/tb-ide/src/documents.rs +++ b/crates/tb-ide/src/documents.rs @@ -51,6 +51,8 @@ impl View { #[derive(Debug)] struct Edit { content: Content, + design_ids: BTreeMap<(String, Option), u64>, + group: Option<(u64, Vec)>, views: Vec<(ViewId, View)>, } #[derive(Debug)] @@ -64,6 +66,7 @@ pub struct Document { binary_source: Option, warnings: Vec, undo: Vec, + design_ids: BTreeMap<(String, Option), u64>, } impl Document { pub fn path(&self) -> Option<&Path> { @@ -257,6 +260,7 @@ impl Project { binary_source: read.binary.then_some(path), warnings: read.warnings, undo: Vec::new(), + design_ids: BTreeMap::new(), }, ); Ok(id) @@ -308,6 +312,7 @@ impl Project { binary_source: None, warnings: Vec::new(), undo: Vec::new(), + design_ids: BTreeMap::new(), }, ); self.manifest.lines.push(ProjectLine::File(path)); @@ -344,6 +349,14 @@ impl Project { properties: BTreeMap::new(), children: Vec::new(), }; + let mut root = root; + for (property, value) in [("WIDTH", 40), ("HEIGHT", 15)] { + let p = tb_frontend::forms::property(root.class, property) + .unwrap() + .0; + root.properties + .insert(p, tb_ui::forms::PropertyValue::Integer(value)); + } self.create( name, Content::Form(Box::new(FormFile::new("1.00", root, ""))), @@ -423,7 +436,82 @@ impl Project { pub fn view_code(&self, id: ViewId) -> Result<&str> { Ok(self.document(self.view(id)?.document)?.code()) } + pub fn design_ids(&mut self, id: DocumentId) -> Result<&BTreeMap<(String, Option), u64>> { + self.document(id)?; + let doc = self.documents.get_mut(&id).unwrap(); + let mut keys = Vec::new(); + if let Content::Form(f) = &doc.content { + fn visit(n: &FormNode, keys: &mut Vec<(String, Option)>) { + keys.push(crate::designer::node_key(n)); + for child in &n.children { + visit(child, keys); + } + } + visit(&f.root, &mut keys); + } + doc.design_ids.retain(|k, _| keys.contains(k)); + for key in keys { + doc.design_ids.entry(key).or_insert_with(next_id); + } + Ok(&doc.design_ids) + } + /// Alle Kandidaten zuerst prüfen; danach eine gemeinsame Undo-Einheit. + pub(crate) fn commit_transaction(&mut self, changes: Vec<(DocumentId, Content)>) -> Result<()> { + let mut ids = Vec::new(); + for (id, content) in &changes { + let old = self.document(*id)?; + ensure!(!ids.contains(id), "Doppeltes Dokument in Transaktion"); + if let Content::Form(f) = content { + frm::read_text(&old.source_path.display().to_string(), &frm::write_text(f))?; + } + ids.push(*id); + } + let changes: Vec<_> = changes + .into_iter() + .filter(|(id, c)| self.documents[id].content != *c) + .collect(); + let ids: Vec<_> = changes.iter().map(|(id, _)| *id).collect(); + let group = next_id(); + for (id, content) in changes { + self.commit_edit(id, content)?; + self.documents + .get_mut(&id) + .unwrap() + .undo + .last_mut() + .unwrap() + .group = Some((group, ids.clone())); + } + Ok(()) + } + pub(crate) fn rekey_design_id( + &mut self, + id: DocumentId, + old: &(String, Option), + new: (String, Option), + stable: u64, + ) { + let doc = self.documents.get_mut(&id).unwrap(); + doc.design_ids.remove(old); + doc.design_ids.insert(new, stable); + } + pub(crate) fn rename_design_ids( + &mut self, + id: DocumentId, + old: &str, + new: &str, + before: &BTreeMap<(String, Option), u64>, + ) { + let doc = self.documents.get_mut(&id).unwrap(); + for ((name, index), value) in before { + if name.eq_ignore_ascii_case(old) { + doc.design_ids.remove(&(name.clone(), *index)); + doc.design_ids.insert((new.to_uppercase(), *index), *value); + } + } + } fn commit_edit(&mut self, id: DocumentId, content: Content) -> Result<()> { + self.design_ids(id)?; let old = self.document(id)?; if old.content == content { return Ok(()); @@ -456,6 +544,8 @@ impl Project { }; let undo = Edit { content: old.content.clone(), + design_ids: old.design_ids.clone(), + group: None, views: self .views .iter() @@ -546,12 +636,31 @@ impl Project { self.commit_edit(id, Content::Form(form)) } pub fn undo(&mut self, id: DocumentId) -> Result { + let group = self.document(id)?.undo.last().and_then(|e| e.group.clone()); + if let Some((number, ids)) = group { + ensure!( + ids.iter().all(|id| self.documents[id] + .undo + .last() + .and_then(|e| e.group.as_ref()) + .is_some_and(|g| g.0 == number)), + "Spätere Änderungen in verbundenen Dokumenten zuerst rückgängig machen" + ); + for id in ids { + self.undo_one(id)?; + } + return Ok(true); + } + self.undo_one(id) + } + fn undo_one(&mut self, id: DocumentId) -> Result { self.document(id)?; let doc = self.documents.get_mut(&id).unwrap(); let Some(edit) = doc.undo.pop() else { return Ok(false); }; doc.content = edit.content; + doc.design_ids = edit.design_ids; doc.revision += 1; for (id, view) in edit.views { if let Some(current) = self.views.get_mut(&id) { diff --git a/crates/tb-ide/src/editor.rs b/crates/tb-ide/src/editor.rs index e8c300f..3106777 100644 --- a/crates/tb-ide/src/editor.rs +++ b/crates/tb-ide/src/editor.rs @@ -563,61 +563,14 @@ impl App { let v = self.editor_view()?; let view = self.project.view(v)?.clone(); let id = view.document(); - let code = self.project.document(id)?.code(); - let input = self.project.sources()?; - let name = d.fields[0].string(); - for unit in &input.units { - let (module, _) = unit.parse(0, &mut Vec::new()); - ensure!( - !module - .procs - .iter() - .any(|p| p.sig.name.eq_ignore_ascii_case(&name)), - "Prozedurname bereits im Projekt vorhanden" - ); - } - let path = self - .project - .document(id)? - .source_path() - .display() - .to_string(); - let unit = input - .units - .iter() - .find(|u| u.segments.iter().any(|s| s.file == path)) - .ok_or_else(|| anyhow!("Keine Übersetzungseinheit"))?; - let first = match self.project.document(id)?.content() { - tb_vm::project_io::Content::Form(f) => { - tb_ui::frm::read_text(&path, &tb_ui::frm::write_text(f))?.code_line() - } - _ => 1, - }; - let line = - first + code[..view.cursor].bytes().filter(|b| *b == b'\n').count() as u32; - let col = view.cursor - line_start(code, view.cursor); - let mut expanded = String::new(); - let mut at = None; - for segment in &unit.segments { - for (i, text) in segment.text.split_inclusive('\n').enumerate() { - if segment.file == path && segment.first_line + i as u32 == line { - at = Some( - expanded.len() + col.min(text.trim_end_matches(['\n', '\r']).len()), - ); - } - expanded.push_str(text); - if !text.ends_with('\n') { - expanded.push('\n'); - } - } - } - let addition = tb_frontend::editing::new_procedure( - &expanded, - at.unwrap_or(expanded.len()), - &name, + let addition = self.new_procedure_text( + id, + view.cursor, + &d.fields[0].string(), function, - ) - .map_err(|e| anyhow!(e))?; + false, + )?; + let code = self.project.document(id)?.code(); let end = code.len(); self.project.replace_text(id, end..end, &addition)?; self.project.view_mut(v)?.cursor = end + 1; @@ -733,6 +686,75 @@ impl App { self.message = format!("{} Treffer ersetzt", ranges.len()); self.editor_scroll() } + pub(crate) fn new_procedure_text( + &self, + id: DocumentId, + cursor: usize, + name: &str, + function: bool, + module_local: bool, + ) -> Result { + let code = self.project.document(id)?.code(); + let input = self.project.sources()?; + for unit in &input.units { + if module_local + && !unit.segments.iter().any(|s| { + s.file + == self + .project + .document(id) + .unwrap() + .source_path() + .display() + .to_string() + }) + { + continue; + } + let (module, _) = unit.parse(0, &mut Vec::new()); + ensure!( + !module + .procs + .iter() + .any(|p| p.sig.name.eq_ignore_ascii_case(name)), + "Prozedurname bereits im Projekt vorhanden" + ); + } + let path = self + .project + .document(id)? + .source_path() + .display() + .to_string(); + let unit = input + .units + .iter() + .find(|u| u.segments.iter().any(|s| s.file == path)) + .ok_or_else(|| anyhow!("Keine Übersetzungseinheit"))?; + let first = match self.project.document(id)?.content() { + tb_vm::project_io::Content::Form(f) => { + tb_ui::frm::read_text(&path, &tb_ui::frm::write_text(f))?.code_line() + } + _ => 1, + }; + let line = first + code[..cursor].bytes().filter(|b| *b == b'\n').count() as u32; + let col = cursor - line_start(code, cursor); + let mut expanded = String::new(); + let mut at = None; + for segment in &unit.segments { + for (i, text) in segment.text.split_inclusive('\n').enumerate() { + if segment.file == path && segment.first_line + i as u32 == line { + at = Some(expanded.len() + col.min(text.trim_end_matches(['\n', '\r']).len())); + } + expanded.push_str(text); + if !text.ends_with('\n') { + expanded.push('\n'); + } + } + } + tb_frontend::editing::new_procedure(&expanded, at.unwrap_or(expanded.len()), name, function) + .map_err(|e| anyhow!(e)) + } pub(crate) fn diagnostic_dialog(&mut self) { if !self.revision_current() { if let Err(e) = self.compile_current() { @@ -840,6 +862,9 @@ impl App { .then_some(self.editor.diagnostics.as_slice()) } pub(crate) fn editor_position(&self) -> Option<(ViewId, DocumentId, usize)> { + if self.mode == crate::app::Mode::Designer { + return None; + } let v = self.editor_view().ok()?; let view = self.project.view(v).ok()?; let code = self.project.document(view.document()).ok()?.code(); diff --git a/crates/tb-ide/src/lib.rs b/crates/tb-ide/src/lib.rs index a5f2783..4453f88 100644 --- a/crates/tb-ide/src/lib.rs +++ b/crates/tb-ide/src/lib.rs @@ -10,3 +10,5 @@ pub mod terminal; pub mod editor; pub mod execution; + +pub mod designer; diff --git a/crates/tb-ide/src/render.rs b/crates/tb-ide/src/render.rs index a2ec874..2a38ee2 100644 --- a/crates/tb-ide/src/render.rs +++ b/crates/tb-ide/src/render.rs @@ -147,15 +147,10 @@ impl App { let v = self.project.view(view).unwrap(); let doc = self.project.document(v.document()).unwrap(); if self.mode == Mode::Designer - && active + && Some(v.document()) == self.designer.document && matches!(doc.content(), tb_vm::project_io::Content::Form(_)) { - put( - f, - inner, - "Formular · Designerwerkzeuge folgen in Change 05", - content_style, - ); + self.design_render(f, inner); } else { let expansion = self.editor.expansions.get(&view); let code = expansion.map(String::as_str).unwrap_or(doc.code()); @@ -334,6 +329,9 @@ impl App { self.hits.push((r, Hit::ProjectMember(row))); } } + kind @ (WindowKind::Toolbox | WindowKind::Palette | WindowKind::MenuDesign) => { + self.design_tool_render(f, kind, inner) + } WindowKind::Output => { f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), inner) } @@ -400,22 +398,27 @@ impl App { ); } if self.mode == Mode::Designer && self.properties { + let (left, geometry) = self.design_bar(); + let width = (geometry.width() as u16 + 1).min(area.width); put( f, - Rect::new(0, 0, area.width, 1), - format!( - "Property: [Caption]↓ Value: […]↓ │ Spalte,Zeile │ BxH{}", - if self.value_focus { - " · Value aktiv" - } else { - "" - } - ), + Rect::new(0, 0, area.width - width, 1), + left, + style(self, 0), + ); + put( + f, + Rect::new(area.width - width, 0, width, 1), + geometry, style(self, 0), ); self.hits.push(( - Rect::new(0, 0, area.width, 1), - Hit::Command(Command::MenuBar), + Rect::new(0, 0, area.width / 2, 1), + Hit::DesignAction("property"), + )); + self.hits.push(( + Rect::new(area.width / 2, 0, area.width - area.width / 2, 1), + Hit::DesignAction("value"), )); } else { put( diff --git a/crates/tb-ide/tests/app.rs b/crates/tb-ide/tests/app.rs index 711b719..b47386d 100644 --- a/crates/tb-ide/tests/app.rs +++ b/crates/tb-ide/tests/app.rs @@ -140,7 +140,8 @@ fn start_snapshot_and_reference_menus_have_all_commands() { let name = item.text(); assert!( reference.contains(name.trim_end_matches('…')) - || item.command == Some(Command::Diagnostics), + || item.command == Some(Command::Diagnostics) + || matches!(item.command, Some(Command::Tool(_))), "Nicht in Referenz: {name}" ); assert!(item.mnemonic().is_some()); diff --git a/crates/tb-ide/tests/designer.rs b/crates/tb-ide/tests/designer.rs new file mode 100644 index 0000000..db6b3b4 --- /dev/null +++ b/crates/tb-ide/tests/designer.rs @@ -0,0 +1,926 @@ +use crossterm::event::{ + Event, KeyCode as K, KeyEvent, KeyModifiers as M, MouseButton as MB, MouseEvent, + MouseEventKind as MK, +}; +use ratatui::{backend::TestBackend, layout::Rect, Terminal}; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, +}; +use tb_frontend::forms::{self, ObjectClass as C}; +use tb_ide::{ + app::{App, DialogKind, Field, Hit, Mode, WindowKind}, + commands::Command, + designer::{self, DesignId}, + documents::{Destination, Project}, +}; +use tb_ui::{forms::PropertyValue as V, frm}; +struct Temp(PathBuf); +impl Temp { + fn new() -> Self { + static N: AtomicUsize = AtomicUsize::new(0); + let p = std::env::temp_dir().join(format!( + "tb-designer-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&p).unwrap(); + Self(p.canonicalize().unwrap()) + } + fn app(&self) -> App { + let mut a = App::new(&self.0, self.0.join("options"), (100, 30)).unwrap(); + a.options.syntax_checking = false; + a.execute(Command::NewForm); + assert_eq!(a.mode, Mode::Designer); + a + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn key(a: &mut App, k: K, m: M) { + a.handle(Event::Key(KeyEvent::new(k, m))); +} +fn plain(a: &mut App, k: K) { + key(a, k, M::NONE); +} +fn root(a: &mut App) -> DesignId { + a.design_objects().unwrap()[0].0 +} +fn node(a: &mut App, id: DesignId) -> frm::FormNode { + a.design_objects() + .unwrap() + .into_iter() + .find(|(i, _)| *i == id) + .unwrap() + .1 +} +fn number(a: &mut App, id: DesignId, p: &str) -> i32 { + match designer::value(&node(a, id), p).unwrap() { + V::Integer(n) => n, + _ => panic!(), + } +} +fn draw(a: &mut App) -> String { + let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap(); + t.draw(|f| a.render(f)).unwrap(); + t.backend() + .buffer() + .content + .iter() + .map(|c| c.symbol()) + .collect() +} +fn mouse(a: &mut App, kind: MK, x: u16, y: u16, m: M) { + a.handle(Event::Mouse(MouseEvent { + kind, + column: x, + row: y, + modifiers: m, + })); +} +fn click(a: &mut App, x: u16, y: u16) { + mouse(a, MK::Down(MB::Left), x, y, M::NONE); + mouse(a, MK::Up(MB::Left), x, y, M::NONE); +} +fn property(a: &mut App, p: &str, v: &str) { + a.design_property(p, v).unwrap(); +} +#[test] +fn all_metadata_tools_persist_and_preview_matches_runtime_without_events() { + let t = Temp::new(); + let mut a = t.app(); + let root = root(&mut a); + property(&mut a, "WIDTH", "70"); + property(&mut a, "HEIGHT", "24"); + for (i, (_, class, style)) in designer::tools().iter().enumerate() { + a.design_place( + *class, + *style, + root, + Rect::new(1 + (i % 8) as u16 * 8, 1 + (i / 8) as u16 * 6, 6, 3), + ) + .unwrap(); + } + let timer = a + .design_objects() + .unwrap() + .into_iter() + .find(|(_, n)| n.class == C::Timer) + .unwrap() + .0; + a.design_select(timer, false).unwrap(); + property(&mut a, "INTERVAL", "1"); + let doc = a.design_document().unwrap(); + a.project.replace_text(doc,0..0,"SUB Form_Load\nPRINT \"NO PREVIEW EXECUTION\"\nEND SUB\nSUB Timer1_Timer\nPRINT \"NO TIMER\"\nEND SUB\n").unwrap(); + let form = a.design_form().unwrap().clone(); + let (mut model, screen) = designer::preview(&form, (100, 30)).unwrap(); + assert!(model.events.is_empty()); + assert!(model.next_deadline().is_none()); + model.timers(10_000); + assert!(model.next_event().is_none()); + assert!(a.session.vm.is_none()); + let mut runtime = tb_ui::forms::FormsModel::new(form.catalog().objects, 100, 30); + form.apply(&mut runtime).unwrap(); + runtime.show(0, false).unwrap(); + let mut expected = tb_runtime::screen::TextScreen::with_size(100, 30); + runtime.render(&mut expected); + for y in 1..=30 { + for x in 1..=100 { + assert_eq!(screen.cell(y, x), expected.cell(y, x)); + } + } + let path = t.0.join("designed.frm"); + a.project + .save_file(doc, Some(&Destination::new(&path))) + .unwrap(); + let reopened = Project::open(&path, vec![]).unwrap(); + let tb_vm::project_io::Content::Form(f) = + reopened.document(reopened.members()[0]).unwrap().content() + else { + panic!() + }; + assert_equivalent(&f.root, &form.root); + assert_eq!(f.root.children.len(), designer::tools().len()); + tb_vm::project_io::load_program(&path).unwrap(); + let listed = a + .menus() + .into_iter() + .find(|m| m.title == "Tools") + .unwrap() + .items; + assert_eq!(listed.len(), designer::tools().len()); +} +#[test] +fn container_multiselect_keyboard_drag_resize_and_undo() { + let t = Temp::new(); + let mut a = t.app(); + let root = root(&mut a); + let frame = a + .design_place(C::Frame, 0, root, Rect::new(3, 2, 28, 10)) + .unwrap(); + let b1 = a + .design_place(C::CommandButton, 0, frame, Rect::new(2, 2, 8, 2)) + .unwrap(); + let b2 = a + .design_place(C::TextBox, 0, frame, Rect::new(12, 2, 8, 2)) + .unwrap(); + a.design_select(b1, false).unwrap(); + a.design_select(b2, true).unwrap(); + key(&mut a, K::Right, M::CONTROL); + assert_eq!(number(&mut a, b1, "LEFT"), 7); + assert_eq!(number(&mut a, b2, "LEFT"), 17); + assert_eq!(number(&mut a, frame, "LEFT"), 3); + a.execute(Command::Undo); + assert_eq!(number(&mut a, b1, "LEFT"), 2); + assert_eq!(number(&mut a, b2, "LEFT"), 12); + a.design_select(b1, false).unwrap(); + key(&mut a, K::Right, M::SHIFT); + assert_eq!(number(&mut a, b1, "WIDTH"), 9); + a.execute(Command::Undo); + draw(&mut a); + let r = a + .designer + .objects + .iter() + .find(|(id, _)| *id == b1) + .unwrap() + .1; + assert_eq!(r.x, a.designer.viewport.x + 3 + 2); + assert_eq!(r.y, a.designer.viewport.y + 2 + 2); + mouse(&mut a, MK::Down(MB::Left), r.x + 2, r.y, M::NONE); + mouse(&mut a, MK::Drag(MB::Left), r.x + 3, r.y + 1, M::NONE); + mouse(&mut a, MK::Up(MB::Left), r.x + 3, r.y + 1, M::NONE); + assert_eq!(number(&mut a, b1, "LEFT"), 3); + assert_eq!(number(&mut a, b1, "TOP"), 3); + draw(&mut a); + let r = a + .designer + .objects + .iter() + .find(|(id, _)| *id == b1) + .unwrap() + .1; + mouse( + &mut a, + MK::Down(MB::Left), + r.right() - 1, + r.bottom() - 1, + M::NONE, + ); + mouse(&mut a, MK::Up(MB::Left), r.right(), r.bottom(), M::NONE); + assert_eq!(number(&mut a, b1, "WIDTH"), 9); + assert_eq!(number(&mut a, b1, "HEIGHT"), 3); + let before = a.design_form().unwrap().clone(); + assert!(a.design_geometry(-100, 0, false).is_err()); + assert_eq!(*a.design_form().unwrap(), before); + a.design_select(b1, false).unwrap(); + plain(&mut a, K::Tab); + assert_eq!( + a.designer.selections[&a.design_document().unwrap()], + vec![b2] + ); + plain(&mut a, K::BackTab); + assert_eq!( + a.designer.selections[&a.design_document().unwrap()], + vec![b1] + ); + draw(&mut a); + let r = a + .designer + .objects + .iter() + .find(|(id, _)| *id == b2) + .unwrap() + .1; + mouse(&mut a, MK::Down(MB::Left), r.x + 1, r.y, M::CONTROL); + mouse(&mut a, MK::Up(MB::Left), r.x + 1, r.y, M::CONTROL); + assert_eq!( + a.designer.selections[&a.design_document().unwrap()].len(), + 2 + ); +} +#[test] +fn property_bar_types_readonly_and_no_status_line() { + let t = Temp::new(); + let mut a = t.app(); + let root = root(&mut a); + let b = a + .design_place(C::CommandButton, 0, root, Rect::new(2, 3, 8, 2)) + .unwrap(); + let before = a.design_form().unwrap().clone(); + for (p, v) in [ + ("HEIGHT", "0"), + ("WIDTH", "x"), + ("BACKCOLOR", "16"), + ("ENABLED", "yes"), + ("PARENT", "anything"), + ] { + let e = a.design_property(p, v).unwrap_err(); + assert!(format!("{e:#}").contains(p)); + assert_eq!(*a.design_form().unwrap(), before); + } + a.designer.property = forms::properties(C::CommandButton) + .iter() + .position(|p| p.name == "CAPTION") + .unwrap(); + plain(&mut a, K::F(2)); + assert!(a.value_focus); + key(&mut a, K::Char('a'), M::CONTROL); + for c in "Hallo".chars() { + plain(&mut a, K::Char(c)); + } + plain(&mut a, K::Enter); + assert_eq!( + designer::value(&node(&mut a, b), "CAPTION"), + Some(V::String("Hallo".into())) + ); + let screen = draw(&mut a); + assert!(screen.contains("2,3")); + assert!(screen.contains("8×2")); + assert!(!screen.contains("Shift+F1=Help")); + plain(&mut a, K::F(10)); + assert!(!a.properties && a.menu.is_some()); + plain(&mut a, K::F(10)); + assert!(a.properties && a.menu.is_none()); + let spin = a + .design_place(C::Spin, 0, root, Rect::new(15, 3, 1, 2)) + .unwrap(); + assert!(a.design_property("WIDTH", "4").is_err()); + property(&mut a, "STYLE", "1"); + assert_eq!(number(&mut a, spin, "WIDTH"), 2); + assert_eq!(number(&mut a, spin, "HEIGHT"), 1); +} +#[test] +fn clipboard_children_arrays_and_delete_preserve_code() { + let t = Temp::new(); + let mut a = t.app(); + let root = root(&mut a); + let frame = a + .design_place(C::Frame, 0, root, Rect::new(1, 1, 20, 10)) + .unwrap(); + let b = a + .design_place(C::CommandButton, 0, frame, Rect::new(1, 1, 8, 2)) + .unwrap(); + property(&mut a, "INDEX", "0"); + a.design_event(b, "CLICK").unwrap(); + a.design_event(b, "CLICK").unwrap(); + key(&mut a, K::F(12), M::SHIFT); + let code = a.design_form().unwrap().code.clone(); + a.design_select(frame, false).unwrap(); + a.execute(Command::Copy); + a.design_select(root, false).unwrap(); + a.execute(Command::Paste); + assert!(a.dialog.is_none(), "{:?}", a.dialog); + let f = a.design_form().unwrap(); + assert_eq!(f.root.children.len(), 2); + let copied = &f.root.children[1]; + assert_eq!(copied.children.len(), 1); + assert_ne!(copied.children[0].name, f.root.children[0].children[0].name); + assert_eq!(designer::node_key(&copied.children[0]).1, Some(0)); + a.execute(Command::Undo); + assert_eq!(a.design_form().unwrap().root.children.len(), 1); + a.design_select(frame, false).unwrap(); + a.execute(Command::Cut); + assert_eq!(a.design_form().unwrap().code, code); + assert!(a.message.contains("ungebunden")); + plain(&mut a, K::Esc); + a.execute(Command::Undo); + assert_eq!(a.design_form().unwrap().root.children.len(), 1); + assert_eq!(node(&mut a, b).class, C::CommandButton); +} +#[test] +fn bound_rename_across_documents_is_atomic_and_undo_restores_all() { + let t = Temp::new(); + let mut a = t.app(); + let root = root(&mut a); + let b = a + .design_place(C::CommandButton, 0, root, Rect::new(2, 2, 8, 2)) + .unwrap(); + let name = node(&mut a, b).name; + let form = a.design_form().unwrap().root.name.clone(); + let doc = a.design_document().unwrap(); + let code=format!("SUB {name}_Click\n{name}.Caption = \"{name} unchanged\"\n' {name}.Caption unchanged\nEND SUB\n"); + a.project.replace_text(doc, 0..0, &code).unwrap(); + let other = a.project.new_module("Other").unwrap(); + let external = format!("PRINT {form}!{name}.Caption\n"); + a.project.replace_text(other, 0..0, &external).unwrap(); + a.design_rename("GoButton").unwrap(); + assert_eq!(node(&mut a, b).name, "GoButton"); + assert!(a + .project + .document(doc) + .unwrap() + .code() + .contains("SUB GoButton_CLICK")); + assert!(a + .project + .document(doc) + .unwrap() + .code() + .contains("GoButton.Caption")); + assert!(a + .project + .document(doc) + .unwrap() + .code() + .contains(&format!("\"{name} unchanged\""))); + assert!(a + .project + .document(other) + .unwrap() + .code() + .contains("!GoButton.Caption")); + a.project.undo(other).unwrap(); + assert_eq!(a.project.document(other).unwrap().code(), external); + assert_eq!(a.project.document(doc).unwrap().code(), code); + assert_eq!(node(&mut a, b).name, name); + let conflict = a + .design_place(C::TextBox, 0, root, Rect::new(14, 2, 8, 2)) + .unwrap(); + let conflict_name = node(&mut a, conflict).name; + a.design_select(b, false).unwrap(); + let before = a.design_form().unwrap().clone(); + assert!(a.design_rename(&conflict_name).is_err()); + assert_eq!(*a.design_form().unwrap(), before); + a.project.replace_text(other, 0..0, "IF \n").unwrap(); + assert!(a.design_rename("Unsafe").is_err()); + assert_eq!(*a.design_form().unwrap(), before); +} +#[test] +fn event_array_signature_twice_and_shared_form_identity() { + let t = Temp::new(); + let mut a = t.app(); + let root = root(&mut a); + let b = a + .design_place(C::TextBox, 0, root, Rect::new(2, 2, 8, 2)) + .unwrap(); + property(&mut a, "INDEX", "0"); + let doc = a.design_document().unwrap(); + let structure = a.design_form().unwrap().root.clone(); + a.design_event(b, "DRAGOVER").unwrap(); + assert_eq!(a.mode, Mode::Environment); + let code = a.project.document(doc).unwrap().code().to_owned(); + assert!(code.contains( + "Index AS INTEGER, SOURCE AS CONTROL, X AS SINGLE, Y AS SINGLE, STATE AS INTEGER" + )); + a.compile_current().unwrap(); + let view = if let WindowKind::Code(v) = a.active_window().unwrap().kind { + v + } else { + panic!() + }; + let cursor = a.project.view(view).unwrap().cursor; + key(&mut a, K::F(12), M::SHIFT); + assert_eq!(a.mode, Mode::Designer); + assert_eq!(a.active_document(), Some(doc)); + assert_eq!(a.design_form().unwrap().root, structure); + assert_eq!(a.designer.selections[&doc], vec![b]); + a.design_event(b, "DRAGOVER").unwrap(); + assert_eq!(a.project.document(doc).unwrap().code(), code); + assert_eq!(a.project.view(view).unwrap().cursor, cursor); + key(&mut a, K::F(12), M::SHIFT); + plain(&mut a, K::F(12)); + assert!(matches!( + a.dialog.as_ref().unwrap().kind, + DialogKind::DesignObject(_) + )); + plain(&mut a, K::Enter); + assert!(matches!( + a.dialog.as_ref().unwrap().kind, + DialogKind::DesignEvent(_) + )); + plain(&mut a, K::Enter); + assert_eq!(a.mode, Mode::Environment); + assert!(a.dialog.is_none()); +} +#[test] +fn menu_hierarchy_arrays_shortcuts_and_palette_use_events() { + let t = Temp::new(); + let mut a = t.app(); + let root = root(&mut a); + let title = a.design_place(C::Menu, 0, root, Rect::default()).unwrap(); + property(&mut a, "CAPTION", "&File"); + let sub = a.design_place(C::Menu, 0, title, Rect::default()).unwrap(); + property(&mut a, "CAPTION", "&Open"); + property(&mut a, "INDEX", "0"); + property(&mut a, "SHORTCUT", "F3"); + let second = a.design_place(C::Menu, 0, title, Rect::default()).unwrap(); + property(&mut a, "CAPTION", "-"); + property(&mut a, "SEPARATOR", "true"); + assert!(a.design_property("CHECKED", "true").is_err()); + a.design_select(sub, false).unwrap(); + a.design_menu_move("down").unwrap(); + a.design_menu_move("up").unwrap(); + assert!(a.design_property("SHORTCUT", "F11").is_err()); + a.execute(Command::MenuDesign); + draw(&mut a); + assert_eq!(a.active_window().unwrap().kind, WindowKind::MenuDesign); + plain(&mut a, K::Enter); + assert!(matches!( + a.dialog.as_ref().unwrap().kind, + DialogKind::DesignMenu(_) + )); + a.dialog.as_mut().unwrap().fields[2] = Field::text("Tag", "kept"); + plain(&mut a, K::Enter); + assert!(a.dialog.is_none(), "{:?}", a.dialog); + a.design_select(root, false).unwrap(); + a.execute(Command::Palette); + plain(&mut a, K::Enter); + a.dialog.as_mut().unwrap().fields[0] = + Field::choice("ForeColor", (0..16).map(|n| n.to_string()).collect(), 4); + a.dialog.as_mut().unwrap().fields[1] = + Field::choice("BackColor", (0..16).map(|n| n.to_string()).collect(), 1); + a.dialog.as_mut().unwrap().fields[2] = Field::toggle("Form", true); + plain(&mut a, K::Enter); + assert_eq!(number(&mut a, root, "FORECOLOR"), 4); + assert_eq!(number(&mut a, root, "BACKCOLOR"), 1); + let path = t.0.join("menu.frm"); + let doc = a.design_document().unwrap(); + a.project + .save_file(doc, Some(&Destination::new(&path))) + .unwrap(); + let reopened = frm::read_text("menu", &fs::read_to_string(&path).unwrap()).unwrap(); + assert_equivalent(&reopened.root, &a.design_form().unwrap().root); + tb_vm::project_io::load_program(&path).unwrap(); + assert_eq!(node(&mut a, second).name, "Menu3"); +} +#[test] +fn toolbox_double_click_tool_drag_grid_and_window_form_selection() { + let t = Temp::new(); + let mut a = t.app(); + let root = root(&mut a); + a.execute(Command::Toolbox); + draw(&mut a); + let r = a + .hits + .iter() + .find_map(|(r, h)| matches!(h, Hit::DesignTool(2)).then_some(*r)) + .unwrap(); + click(&mut a, r.x + 1, r.y); + click(&mut a, r.x + 1, r.y); + assert_eq!(a.design_form().unwrap().root.children.len(), 1); + a.execute(Command::Tool("TextBox")); + draw(&mut a); + let r = a + .designer + .objects + .iter() + .find(|(i, _)| *i == root) + .unwrap() + .1; + mouse(&mut a, MK::Down(MB::Left), r.x + 15, r.y + 3, M::NONE); + mouse(&mut a, MK::Drag(MB::Left), r.x + 22, r.y + 4, M::NONE); + mouse(&mut a, MK::Up(MB::Left), r.x + 22, r.y + 4, M::NONE); + assert!(a.dialog.is_none(), "{:?}", a.dialog); + let textbox = a.design_form().unwrap().root.children.last().unwrap(); + assert_eq!(textbox.class, C::TextBox); + assert_eq!(designer::value(textbox, "LEFT"), Some(V::Integer(15))); + assert_eq!(designer::value(textbox, "WIDTH"), Some(V::Integer(8))); + a.execute(Command::Grid); + assert!(a.designer.grid); + assert!(draw(&mut a).contains('·')); + a.execute(Command::Grid); + assert!(!a.designer.grid); + let first = a.design_document().unwrap(); + a.execute(Command::NewForm); + let second = a.design_document().unwrap(); + assert_ne!(first, second); + let command=a.window_commands().into_iter().find(|(_,c)|matches!(c,Command::FocusWindow(id) if a.windows.iter().any(|w|w.id==*id&&matches!(w.kind,WindowKind::Code(v) if a.project.view(v).unwrap().document()==first)))).unwrap().1; + a.execute(command); + assert_eq!(a.design_document().unwrap(), first); +} +#[test] +fn binary_designer_import_requires_text_target_and_compiles() { + let t = Temp::new(); + let bytes: Vec = include_str!("../../tb-ui/tests/data/new.frm.hex") + .split_whitespace() + .map(|s| u8::from_str_radix(s, 16).unwrap()) + .collect(); + let source = t.0.join("binary.frm"); + fs::write(&source, &bytes).unwrap(); + let mut a = App::new(&t.0, t.0.join("options"), (100, 30)).unwrap(); + a.load_initial_project(source.clone()).unwrap(); + a.execute(Command::Form); + plain(&mut a, K::Enter); + let root = root(&mut a); + a.design_select(root, false).unwrap(); + property(&mut a, "CAPTION", "Imported and edited"); + let doc = a.design_document().unwrap(); + assert!(a.project.save_file(doc, None).is_err()); + let path = t.0.join("text.frm"); + a.project + .save_file(doc, Some(&Destination::new(&path))) + .unwrap(); + assert_eq!(fs::read(&source).unwrap(), bytes); + tb_vm::project_io::load_program(&path).unwrap(); +} + +fn assert_equivalent(a: &frm::FormNode, b: &frm::FormNode) { + assert_eq!( + (a.class, &a.name, designer::node_key(a)), + (b.class, &b.name, designer::node_key(b)) + ); + for p in forms::properties(a.class) { + assert_eq!( + designer::value(a, p.name), + designer::value(b, p.name), + "{}.{}", + a.name, + p.name + ); + } + assert_eq!(a.children.len(), b.children.len()); + for (a, b) in a.children.iter().zip(&b.children) { + assert_equivalent(a, b); + } +} + +#[test] +fn rename_preserves_shadowed_names_and_handles_form_aliases_and_include_calls() { + let t = Temp::new(); + let mut a = t.app(); + let r = root(&mut a); + let b = a + .design_place(C::CommandButton, 0, r, Rect::new(2, 2, 8, 2)) + .unwrap(); + let doc = a.design_document().unwrap(); + let name = node(&mut a, b).name; + let form = node(&mut a, r).name; + let code=format!("SUB {name}_Click\nDIM {name}_Click AS INTEGER\n{name}_Click = 7\nPRINT {name}_Click\nEND SUB\nSUB {form}_Load\n{name}.Caption=\"Hi\"\nEND SUB\n"); + a.project.replace_text(doc, 0..0, &code).unwrap(); + // In eine andere Datei gebundene Aufrufe müssen mit umbenannt werden. + let other = a.project.new_module("Calls").unwrap(); + let call = format!("DECLARE SUB {name}_Click ()\nCALL {name}_Click\n"); + a.project.replace_text(other, 0..0, &call).unwrap(); + a.design_rename("Launch").unwrap(); + let changed = a.project.document(doc).unwrap().code(); + assert!(changed.contains(&format!("DIM {name}_Click AS INTEGER"))); + assert!(changed.contains(&format!("PRINT {name}_Click"))); + assert!(changed.contains("SUB Launch_CLICK")); + assert!(a + .project + .document(other) + .unwrap() + .code() + .contains("CALL Launch_CLICK")); + a.design_select(r, false).unwrap(); + a.design_event(r, "LOAD").unwrap(); + assert!(!a + .project + .document(doc) + .unwrap() + .code() + .contains("SUB Form_LOAD")); + key(&mut a, K::F(12), M::SHIFT); + a.design_select(r, false).unwrap(); + a.design_rename("MainForm").unwrap(); + assert_eq!(node(&mut a, r).name, "MainForm"); + assert!(a + .project + .document(doc) + .unwrap() + .code() + .contains("SUB MainForm_LOAD")); +} +#[test] +fn existing_default_typed_event_and_all_metadata_signatures_compile() { + let t = Temp::new(); + let mut a = t.app(); + let r = root(&mut a); + let b = a + .design_place(C::CommandButton, 0, r, Rect::new(2, 2, 8, 2)) + .unwrap(); + property(&mut a, "INDEX", "0"); + let name = node(&mut a, b).name; + let doc = a.design_document().unwrap(); + let code = format!("DEFINT I,K,S\nSUB {name}_KeyDown (Index, KeyCode, Shift)\nEND SUB\n"); + a.project.replace_text(doc, 0..0, &code).unwrap(); + a.design_event(b, "KEYDOWN").unwrap(); + assert_eq!(a.project.document(doc).unwrap().code(), code); + key(&mut a, K::F(12), M::SHIFT); + for event in forms::events(C::CommandButton) { + a.design_event(b, event).unwrap(); + a.compile_current().unwrap(); + key(&mut a, K::F(12), M::SHIFT); + } +} +#[test] +fn all_eight_handles_resize_transactionally_and_preserve_identity_after_structure_edits() { + let t = Temp::new(); + let mut a = t.app(); + let r = root(&mut a); + let b = a + .design_place(C::TextBox, 0, r, Rect::new(8, 5, 10, 5)) + .unwrap(); + for (horizontal, vertical) in [ + (-1, -1), + (0, -1), + (1, -1), + (-1, 0), + (1, 0), + (-1, 1), + (0, 1), + (1, 1), + ] { + a.design_select(b, false).unwrap(); + draw(&mut a); + let rect = a + .designer + .objects + .iter() + .find(|(id, _)| *id == b) + .unwrap() + .1; + let x = match horizontal { + -1 => rect.x, + 0 => rect.x + rect.width / 2, + _ => rect.right() - 1, + }; + let y = match vertical { + -1 => rect.y, + 0 => rect.y + rect.height / 2, + _ => rect.bottom() - 1, + }; + let before = a.design_form().unwrap().clone(); + mouse(&mut a, MK::Down(MB::Left), x, y, M::NONE); + mouse(&mut a, MK::Up(MB::Left), x + 1, y + 1, M::NONE); + assert!(a.dialog.is_none(), "{:?}", a.dialog); + assert_eq!(number(&mut a, b, "WIDTH"), 10 + horizontal); + assert_eq!(number(&mut a, b, "HEIGHT"), 5 + vertical); + assert_eq!(number(&mut a, b, "LEFT"), 8 + i32::from(horizontal < 0)); + assert_eq!(number(&mut a, b, "TOP"), 5 + i32::from(vertical < 0)); + a.execute(Command::Undo); + assert_eq!(*a.design_form().unwrap(), before); + } + let old_id = b; + a.design_select(b, false).unwrap(); + property(&mut a, "INDEX", "0"); + assert_eq!(node(&mut a, old_id).class, C::TextBox); + a.execute(Command::Undo); + assert_eq!(designer::node_key(&node(&mut a, old_id)).1, None); + a.design_select(b, false).unwrap(); + a.execute(Command::Clear); + plain(&mut a, K::Esc); + let replacement = a + .design_place(C::TextBox, 0, r, Rect::new(1, 1, 5, 2)) + .unwrap(); + assert_ne!(replacement, b); + a.execute(Command::Undo); + a.execute(Command::Undo); + assert_eq!(node(&mut a, b).class, C::TextBox); +} +#[test] +fn menu_indent_outdent_rename_and_field_changes_have_one_undo() { + let t = Temp::new(); + let mut a = t.app(); + let r = root(&mut a); + let title = a.design_place(C::Menu, 0, r, Rect::default()).unwrap(); + let child = a.design_place(C::Menu, 0, title, Rect::default()).unwrap(); + let sibling = a.design_place(C::Menu, 0, title, Rect::default()).unwrap(); + a.design_menu_move("in").unwrap(); + assert_eq!(node(&mut a, child).children.len(), 1); + a.design_menu_move("out").unwrap(); + assert_eq!(node(&mut a, title).children.len(), 2); + let before = a.design_form().unwrap().clone(); + a.execute(Command::MenuDesign); + plain(&mut a, K::Enter); + let d = a.dialog.as_mut().unwrap(); + d.fields[0] = Field::text("CtlName", "SaveMenu"); + d.fields[1] = Field::text("Caption", "&Save"); + d.fields[2] = Field::text("Tag", "saved"); + d.fields[3] = Field::text("Index", "0"); + plain(&mut a, K::Enter); + assert!(a.dialog.is_none(), "{:?}", a.dialog); + assert_eq!(node(&mut a, sibling).name, "SaveMenu"); + a.execute(Command::Undo); + assert_eq!(*a.design_form().unwrap(), before); + assert_eq!(node(&mut a, sibling).name, "Menu3"); +} + +#[test] +fn property_menu_buttons_palette_drag_and_modal_mouse_are_connected() { + let t = Temp::new(); + let mut a = t.app(); + let r = root(&mut a); + let before = a.design_form().unwrap().code.clone(); + plain(&mut a, K::Char('x')); + assert_eq!(a.design_form().unwrap().code, before); + draw(&mut a); + click(&mut a, 1, 0); + assert!(matches!( + a.dialog.as_ref().unwrap().kind, + DialogKind::DesignProperty + )); + plain(&mut a, K::Enter); + assert!(a.value_focus); + plain(&mut a, K::Esc); + a.execute(Command::Palette); + draw(&mut a); + let swatch = a + .hits + .iter() + .find_map(|(r, h)| matches!(h, Hit::DesignPaint("BACKCOLOR", 5)).then_some(*r)) + .unwrap(); + let form = a + .designer + .objects + .iter() + .find(|(id, _)| *id == r) + .unwrap() + .1; + mouse(&mut a, MK::Down(MB::Left), swatch.x, swatch.y, M::NONE); + mouse( + &mut a, + MK::Drag(MB::Left), + form.right() - 3, + form.bottom() - 3, + M::NONE, + ); + mouse( + &mut a, + MK::Up(MB::Left), + form.right() - 3, + form.bottom() - 3, + M::NONE, + ); + assert_eq!(number(&mut a, r, "BACKCOLOR"), 5); + a.execute(Command::MenuDesign); + draw(&mut a); + let insert = a + .hits + .iter() + .find_map(|(r, h)| matches!(h, Hit::DesignAction("insert")).then_some(*r)) + .unwrap(); + click(&mut a, insert.x, insert.y); + assert_eq!(a.design_form().unwrap().root.children.len(), 1); + draw(&mut a); + let edit = a + .hits + .iter() + .find_map(|(r, h)| matches!(h, Hit::DesignAction("menu")).then_some(*r)) + .unwrap(); + click(&mut a, edit.x, edit.y); + assert!(matches!( + a.dialog.as_ref().unwrap().kind, + DialogKind::DesignMenu(_) + )); + draw(&mut a); + let before = a.design_form().unwrap().clone(); + click(&mut a, 1, 0); + assert_eq!(*a.design_form().unwrap(), before); + assert!(matches!( + a.dialog.as_ref().unwrap().kind, + DialogKind::DesignMenu(_) + )); +} +#[test] +fn shared_include_ambiguity_aborts_rename_and_event_defaults_use_includes() { + let t = Temp::new(); + fs::write(t.0.join("shared.bi"), "PRINT Button.Caption\n").unwrap(); + for (file, name) in [("a.frm", "AForm"), ("b.frm", "BForm")] { + fs::write(t.0.join(file),format!("VERSION 1.00\nBegin Form {name}\n Width=40\n Height=15\n Begin CommandButton Button\n Width=8\n End\nEnd\n'$INCLUDE: 'shared.bi'\n")).unwrap(); + } + fs::write(t.0.join("p.mak"), "a.frm\nb.frm\n").unwrap(); + let mut a = App::new(&t.0, t.0.join("options"), (100, 30)).unwrap(); + a.load_initial_project(t.0.join("p.mak")).unwrap(); + a.options.syntax_checking = false; + a.execute(Command::Form); + plain(&mut a, K::Enter); + let b = a.design_objects().unwrap()[1].0; + a.design_select(b, false).unwrap(); + let before: Vec<_> = a + .project + .documents() + .map(|(id, d)| (id, d.content().clone())) + .collect(); + assert!(a.design_rename("Changed").is_err()); + for (id, c) in before { + assert_eq!(*a.project.document(id).unwrap().content(), c); + } + let t = Temp::new(); + let mut a = t.app(); + let r = root(&mut a); + let b = a + .design_place(C::CommandButton, 0, r, Rect::new(1, 1, 8, 2)) + .unwrap(); + fs::write(t.0.join("defaults.bi"), "DEFINT A-Z\n").unwrap(); + let doc = a.design_document().unwrap(); + a.project + .replace_text(doc, 0..0, "'$INCLUDE: 'defaults.bi'\n") + .unwrap(); + a.design_event(b, "CLICK").unwrap(); + assert!(a + .project + .document(doc) + .unwrap() + .code() + .contains("DEFINT A-Z\n\nEND SUB")); + a.compile_current().unwrap(); +} + +#[test] +fn imported_type_shadow_is_not_an_object_reference_during_rename() { + let t = Temp::new(); + let mut a = t.app(); + let r = root(&mut a); + let b = a + .design_place(C::CommandButton, 0, r, Rect::new(1, 1, 8, 2)) + .unwrap(); + let doc = a.design_document().unwrap(); + let name = node(&mut a, b).name; + let types = a.project.new_module("Types").unwrap(); + a.project + .replace_text(types, 0..0, "TYPE Imported\nCaption AS STRING\nEND TYPE\n") + .unwrap(); + let code = format!( + "DIM {name} AS Imported\n{name}.Caption = \"UDT field\"\nSUB {name}_Click\nEND SUB\n" + ); + a.project.replace_text(doc, 0..0, &code).unwrap(); + a.design_rename("RunButton").unwrap(); + let code = a.project.document(doc).unwrap().code(); + assert!(code.contains(&format!("{name}.Caption = \"UDT field\""))); + assert!(code.contains("SUB RunButton_CLICK")); +} + +#[test] +fn events_with_the_same_name_are_local_to_their_form() { + let t = Temp::new(); + let mut a = t.app(); + let r1 = root(&mut a); + let b1 = a + .design_place(C::CommandButton, 0, r1, Rect::new(1, 1, 8, 2)) + .unwrap(); + let d1 = a.design_document().unwrap(); + a.design_event(b1, "CLICK").unwrap(); + key(&mut a, K::F(12), M::SHIFT); + a.design_event(r1, "LOAD").unwrap(); + key(&mut a, K::F(12), M::SHIFT); + a.execute(Command::NewForm); + let r2 = root(&mut a); + let b2 = a + .design_place(C::CommandButton, 0, r2, Rect::new(1, 1, 8, 2)) + .unwrap(); + let d2 = a.design_document().unwrap(); + a.design_event(b2, "CLICK").unwrap(); + assert_eq!(a.active_document(), Some(d2)); + key(&mut a, K::F(12), M::SHIFT); + a.design_event(r2, "LOAD").unwrap(); + assert_eq!(a.active_document(), Some(d2)); + a.compile_current().unwrap(); + assert!(a + .project + .document(d1) + .unwrap() + .code() + .contains("SUB Form_LOAD")); + assert!(a + .project + .document(d2) + .unwrap() + .code() + .contains("SUB Form_LOAD")); +} diff --git a/crates/tb-runtime/src/host.rs b/crates/tb-runtime/src/host.rs index 43f42ec..1ead8ed 100644 --- a/crates/tb-runtime/src/host.rs +++ b/crates/tb-runtime/src/host.rs @@ -293,6 +293,15 @@ impl Host for CaptureHost { } } +/// Shared child command, inheriting the foreground terminal and stdio. +pub fn shell_command(command: &str) -> std::process::Command { + let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "sh" }); + if !command.is_empty() { + child.args([if cfg!(windows) { "/C" } else { "-c" }, command]); + } + child +} + #[cfg(test)] mod tests { use super::*; @@ -397,12 +406,3 @@ mod tests { assert_eq!(h.next_event(false), None); } } - -/// Shared child command, inheriting the foreground terminal and stdio. -pub fn shell_command(command: &str) -> std::process::Command { - let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "sh" }); - if !command.is_empty() { - child.args([if cfg!(windows) { "/C" } else { "-c" }, command]); - } - child -} diff --git a/crates/tb-ui/src/forms.rs b/crates/tb-ui/src/forms.rs index 4230dc8..ad5e471 100644 --- a/crates/tb-ui/src/forms.rs +++ b/crates/tb-ui/src/forms.rs @@ -25,7 +25,7 @@ pub enum PropertyValue { } impl PropertyValue { - fn from_default(default: PropertyDefault, ty: PropertyType) -> Self { + pub fn from_default(default: PropertyDefault, ty: PropertyType) -> Self { match default { PropertyDefault::Integer(v) if ty == PropertyType::IntegerArray => { Self::IntegerArray(vec![v; 18]) @@ -1659,7 +1659,7 @@ impl FormsModel { false } - fn rect(&self, key: ObjectKey) -> Option<(usize, usize, usize, usize)> { + pub fn rect(&self, key: ObjectKey) -> Option<(usize, usize, usize, usize)> { self.instance(key).ok()?; let mut left = self.integer(key, "LEFT").unwrap_or(0).max(0) as usize + 1; let mut top = self.integer(key, "TOP").unwrap_or(0).max(0) as usize + 1; diff --git a/crates/tb-vm/src/project.rs b/crates/tb-vm/src/project.rs index 24400db..1737619 100644 --- a/crates/tb-vm/src/project.rs +++ b/crates/tb-vm/src/project.rs @@ -60,6 +60,26 @@ pub fn compile_project( } impl ProjectCompiler { + /// Bindungen aus den tatsächlich übersetzten Modulen einschließlich ihrer Imports. + pub fn bound_form_references( + &self, + ) -> Result> { + let mut result = tb_frontend::sema::BoundFormReferences::default(); + for product in &self.products { + let bound = tb_frontend::sema::bound_form_references(&product.module, &product.catalog); + result.objects.extend(bound.objects); + // Importierte Prototypen im Cache haben keine physische Quellposition. + result + .procedures + .extend(bound.procedures.into_iter().filter(|(pos, _)| pos.line > 0)); + result.diagnostics.extend(bound.diagnostics); + } + if result.diagnostics.is_empty() { + Ok(result) + } else { + Err(result.diagnostics) + } + } pub fn compile( &mut self, name: &str, diff --git a/docs/ide-bedienung.md b/docs/ide-bedienung.md index 46c3c8f..2e70750 100644 --- a/docs/ide-bedienung.md +++ b/docs/ide-bedienung.md @@ -43,7 +43,7 @@ Mausmodus und Alternate Screen zurück. - 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. + Die Formularbearbeitung ist unten beschrieben. ## Speichern und Wechseln @@ -127,7 +127,7 @@ 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 03 +## Befehlsabdeckung nach Change 05 Alle Referenzbefehle sind in ihrer Menüfolge vorhanden. Fachbefehle der folgenden Changes sind sichtbar deaktiviert und zeigen einen @@ -138,17 +138,17 @@ Output-Fokus unterbricht Ctrl+C sofort die Sitzung, ohne eine BASIC-Taste einzur | Bereich | Rahmenaktion jetzt | Fachaktion im Folgechange | | --- | --- | --- | | File | New/Open/Save Project, New Form/Module, Add/Remove File, Save File/As, Load/Save Text, Print (UTF-8-Datei), Shell, Exit | — | -| Edit | Undo, Cut/Copy/Paste/Clear, New Sub/Function | Event Procedures: 05 | -| View | Code/Form-Auswahl, Prozedurauswahl, Included File/Lines, Diagnostics, Designer Menu Bar/Value-Fokus, Output Screen | Grid Lines: 05; Next Statement: 06 | +| Edit | Undo, Cut/Copy/Paste/Clear, New Sub/Function, Event Procedures | — | +| View | Code/Form-Auswahl, Prozedurauswahl, Included File/Lines, Diagnostics, Designer Menu Bar/Value-Fokus, Grid Lines, Output Screen | Next Statement: 06 | | Search | Find, Selected Text, Repeat Last Find, Change | — | | Run | Start/Restart/Continue/Pause, COMMAND$, wirksame Startdatei und beide Make-Dialoge | 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 einschließlich Zeilenprüfung | Kontext-Hilfe: 07 | | Window | New Window, Arrange All, Calls/Debug/Help/Immediate/Output/Project, aktive Codeliste, alle Fensterkürzel | 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 | +| Designer Tools/Window | Vollständige Toolbox einschließlich Spin, Formliste, Color Palette, Menu Design Window | — | | Help | Vollständiges Menü, About und Help-Fensterrahmen | Index, Contents, Keyboard, Topic, Using Help, Tutorial: 07 | -Editor, inkrementelle Übersetzung und Ausführung sind umgesetzt. Die gesamte +Editor, inkrementelle Übersetzung, Ausführung und Formulardesigner sind umgesetzt. Die gesamte Phase-5-Abnahme erfolgt in Change 08. ## Programme ausführen @@ -298,7 +298,7 @@ Erfolge und Diagnosen gehören zu Projektrevision, expandierten Quellen und Form Stand zurück; ein Fehler macht keinen früheren Erfolg aktuell. Geöffnete Include-Puffer bleiben maßgeblich gegenüber externen Dateiänderungen. Noch nicht geöffnete Includes werden erneut vom Loader gelesen und verglichen. Start und -Fortsetzen der VM werden in Change 04 an diese Schnittstelle angebunden. +Fortsetzen der VM verwenden diese Schnittstelle. Release-Messungen und Aufruf stehen in [tbvm-design.md](tbvm-design.md). @@ -307,3 +307,80 @@ Die Ausführungstests liegen in `crates/tb-ide/tests/execution.rs` und `cargo build -p tb-ide && python3 tests/support/ide-execution-pty.py` unter Unix in einem unsichtbaren PTY die echten Shell-/Terminalübergaben und das Fortsetzen nach STOP. Das Skript verwendet ausschließlich temporäre Projekte. + + +## Formulare gestalten + +File → New Form öffnet ein neues FRM-Dokument mit 40×15 Zellen. Toolbox +links und Tools-Menü verwenden dieselben Klassenmetadaten: auch Datei-, +Verzeichnis- und Laufwerkslisten, Timer sowie VSpin/HSpin sind enthalten. +Doppelklick auf einen Toolboxeintrag platziert einen gültigen Standard im +gewählten Container (sonst auf der Form); mit Tastatur: Window → Toolbox, +Pfeile, Enter. Ein einfacher Klick oder Tools wählt das Werkzeug zum +Aufziehen auf der Designfläche. Esc beendet die Werkzeugwahl. + +Die Vorschau verwendet FormsModel und TextScreen, führt aber keinen +Ereigniscode und keine Timer aus. Timer erscheinen als `[T]`-Marken. +Klicken wählt auch deaktivierte/unsichtbare Controls aus, Ctrl+Klick bildet +Mehrfachauswahlen. Tab/Shift+Tab folgt TabIndex. Drag verschiebt, die acht +Handles skalieren. Pfeile bewegen um eine, Ctrl+Pfeile um fünf Zellen; +Shift+Pfeile ändern die Größe (mit Ctrl ebenfalls fünf). View → Grid Lines +schaltet das Raster. Koordinaten sind relativ zum tatsächlichen Container. +Unzulässige Größen/Positionen werden vor der gesamten Änderung abgewiesen. + +Oben wechseln F10 und F11 zwischen Properties Bar und Menü. Klicken auf +die Property-Auswahl öffnet die Klassenproperties; Bild auf/ab blättert +sie auf der Designfläche. F2 fokussiert Value, Ctrl+A ersetzt den Wert, +Enter übernimmt und Esc verwirft die Eingabe. Der rechte Bereich zeigt +Position und Größe auch bei langen Namen/Werten. Boolean-Werte sind +`true`/`false` beziehungsweise `-1`/`0`. Typen, Metadatenbereiche und +Laufzeitregeln werden geprüft; schreibgeschützte Properties bleiben erhalten. +Spin.Style wählt 0 (vertikal, 1×2) oder 1 (horizontal, 2×1); Width/Height +sind bei Spin schreibgeschützt. Explizites Index=0 erzeugt ein Control-Array. + +Cut/Copy/Paste/Clear und Undo wirken im Designer auf Controls; die +Textzwischenablage des Editors bleibt getrennt. Container nehmen ihre +Kinder mit. Paste verlangt einen Container und vergibt freie Namen, +wobei die Arraygruppen und expliziten Indizes erhalten bleiben. Eine +Mehrfachaktion ist genau eine Undo-Einheit. Nach Clear/Cut weist ein Dialog +auf erhaltenen, nun ungebundenen Code hin. Code wird nicht gelöscht. + +CtlName/FormName ändern auch gebundene Codeverweise und Ereignisnamen +in betroffenen Projektdateien und Includes. Kommentare, Strings und +unabhängige Variablen bleiben erhalten. Beide Projektstände werden +übersetzt und ihre Objektbindungen verglichen. Konflikte oder mehrdeutige +Include-Verweise verhindern die gesamte Änderung. Undo aus einer beteiligten +Datei nimmt sie überall zurück; spätere Änderungen in einer anderen +beteiligten Datei müssen zuerst rückgängig gemacht werden. + +Window → Menu Design Window zeigt die Menühierarchie. `+`/Insert fügt unter +dem gewählten Menü (sonst unter der Form) ein, Del löscht, Enter/Edit öffnet +Caption, CtlName, Tag, Index, Flags und Shortcut. `&` markiert Access-Keys, +`-` einen Separator. Leerer Index bedeutet skalar; `0` ist ein Arrayindex. +Die Pfeilbuttons beziehungsweise Ctrl+Links/Rechts ändern die Ebene, +Ctrl+Auf/Ab die Reihenfolge. Änderungen des Dialogs sind gemeinsam rücknehmbar. +Angeboten werden F1–F10 mit unterstützten Modifikatoren sowie Ctrl+Buchstaben +außer Ctrl+C, das dem Programmabbruch vorbehalten bleibt. Unbekannte Importwerte +werden nicht als gültige Shortcuts angeboten; Menütitel und Separatoren tragen +keinen Shortcut. + +Window → Color Palette bietet getrennte ForeColor-/BackColor-Zeilen. Eine +Farbfläche auf Form/Control ziehen trägt diese Farbe auf. Enter öffnet +zusätzlich den Dialog zum gemeinsamen Auftragen beider Farben auf die +Auswahl oder ausdrücklich die Form. Window bietet außerdem Toolbox, Help +und die Formliste; der Help-Inhalt folgt in Change 07. + +F12/Edit → Event Procedures wählt Objekt und erlaubtes Ereignis. Eine +vorhandene gebundene Prozedur wird geöffnet; sonst entsteht ein New-Sub-Rumpf +mit den bestehenden Eventtypen, BYREF-Parametern und gegebenenfalls führendem +`Index AS INTEGER`. DEFtype aus Includes bleibt wirksam. Zweiter Aufruf +öffnet dieselbe Prozedur. Shift+F12/View → Form führt zum zugehörigen +Dokument und Control zurück; View → Code behält die Codeposition. Struktur +und Code bleiben dabei ungespeichert im gemeinsamen Dokument. + +Save File/As/Project verwendet den vorhandenen FRM-Writer. Dieser lässt +überflüssige Defaultwerte aus, erhält aber explizites Index=0. Binärimporte +benötigen weiter ein separates Textziel. Die Designer-Tests prüfen alle +Klassen, Geometrie, Properties, Maus/Tastatur, Transaktionen, Ereignisse und +Speicher-Roundtrips. Der CLI-Integrationstest erzeugt eine FRM über dieselben +Designeraktionen und übersetzt sie mit dem echten `tbc build`-Prozess. 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 5366cc1..40c7200 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 @@ -32,7 +32,7 @@ Besondere Risiken sind unverändert ausgeführte alte Kompilate nach einer Text | 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](../2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md) | 01, 02 | Vollständige Codebearbeitung, Diagnose und schneller aktueller Compile | | 04 | [Ausführung und Output](../2026-09-06-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 | +| 05 | [Formular-Designer](../2026-09-06-phase-5-05-formular-designer/proposal.md) | 01, 02, 03 | Visuelles Gestalten und konsistenter Ereigniscode | | 06 | [Debugger und Direktfenster](../../phase-5-06-debugger-und-direktfenster/proposal.md) | 03, 04 | Quellgenaues Debuggen, Watches, Immediate, History und Fehlerhalte | | 07 | [Hilfesystem](../../phase-5-07-hilfesystem/proposal.md) | 02, 03 | Offline-Markdown-Hilfe und Kontextnavigation | | 08 | [Integration und Phasenabnahme](../../phase-5-08-integration-und-phasenabnahme/proposal.md) | 01–07 | Durchgängiger Arbeitsablauf, Befehlsabdeckung und nachgewiesener PLAN-Status | diff --git a/openspec/changes/phase-5-05-formular-designer/.openspec.yaml b/openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/.openspec.yaml similarity index 100% rename from openspec/changes/phase-5-05-formular-designer/.openspec.yaml rename to openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/.openspec.yaml diff --git a/openspec/changes/phase-5-05-formular-designer/design.md b/openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/design.md similarity index 100% rename from openspec/changes/phase-5-05-formular-designer/design.md rename to openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/design.md diff --git a/openspec/changes/phase-5-05-formular-designer/proposal.md b/openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/proposal.md similarity index 71% rename from openspec/changes/phase-5-05-formular-designer/proposal.md rename to openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/proposal.md index 335035d..8dd9bc5 100644 --- a/openspec/changes/phase-5-05-formular-designer/proposal.md +++ b/openspec/changes/archive/2026-09-06-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](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). +**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../2026-09-06-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. +**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-05-formular-designer/specs/ide-formulardesigner/spec.md b/openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/specs/ide-formulardesigner/spec.md similarity index 100% rename from openspec/changes/phase-5-05-formular-designer/specs/ide-formulardesigner/spec.md rename to openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/specs/ide-formulardesigner/spec.md diff --git a/openspec/changes/phase-5-05-formular-designer/tasks.md b/openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/tasks.md similarity index 68% rename from openspec/changes/phase-5-05-formular-designer/tasks.md rename to openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/tasks.md index 24a0737..2e7346f 100644 --- a/openspec/changes/phase-5-05-formular-designer/tasks.md +++ b/openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/tasks.md @@ -1,20 +1,20 @@ ## 1. Designmodell und Vorschau -- [ ] 1.1 Den FormFile-Dokumentbaum aus 01 mit stabilen Designobjekt-IDs an einen isolierten Preview-FormsModel/TextScreen anbinden; Tests belegen identische Geometrie und ausbleibende Load-/Timer-Nebenwirkungen. -- [ ] 1.2 Toolbox und Tools-Menü aus den vorhandenen Klassenmetadaten ableiten und Platzieren per Doppelklick/Aufziehen umsetzen; ein tabellarischer Test erzeugt jede unterstützte Designzeitklasse einschließlich Spinvarianten und Timer. -- [ ] 1.3 Auswahl, Tab-Reihenfolge, Ctrl+Klick-Mehrfachauswahl, Drag, Handles und Raster implementieren; Maus-/Tastentests belegen Containerkoordinaten sowie 1-/5-Zellen-Schritte und Shift-Skalierung. +- [x] 1.1 Den FormFile-Dokumentbaum aus 01 mit stabilen Designobjekt-IDs an einen isolierten Preview-FormsModel/TextScreen anbinden; Tests belegen identische Geometrie und ausbleibende Load-/Timer-Nebenwirkungen. +- [x] 1.2 Toolbox und Tools-Menü aus den vorhandenen Klassenmetadaten ableiten und Platzieren per Doppelklick/Aufziehen umsetzen; ein tabellarischer Test erzeugt jede unterstützte Designzeitklasse einschließlich Spinvarianten und Timer. +- [x] 1.3 Auswahl, Tab-Reihenfolge, Ctrl+Klick-Mehrfachauswahl, Drag, Handles und Raster implementieren; Maus-/Tastentests belegen Containerkoordinaten sowie 1-/5-Zellen-Schritte und Shift-Skalierung. ## 2. Eigenschaften und strukturierte Änderungen -- [ ] 2.1 Properties Bar mit F2-/F10-Fokus und typisierter Propertybearbeitung implementieren; Tests prüfen gültige/ungültige Werte, sichtbare Koordinaten und fehlende Designer-Statuszeile. -- [ ] 2.2 Control-Cut/Copy/Paste/Clear und Undo über gemeinsame Dokumenttransaktionen umsetzen; Tests belegen Containerkinder, konfliktfreie Namen/Array-Indizes und vollständige Rücknahme einer Mehrfachaktion. -- [ ] 2.3 Den Menüdesigner für Caption, CtlName, Tag, Index, Flags, Shortcut, Ebenen und Reihenfolge implementieren; ein FRM-Roundtrip prüft Access-Keys, Separatoren und indizierte Untermenüs. -- [ ] 2.4 Color Palette und Designer-Window-Befehle anbinden; Eventtests prüfen Fore-/BackColor-Auftrag, Werkzeugfenster und Formauswahl. -- [ ] 2.5 Form-/Control-Umbenennung über gebundene Codeverweise transaktional ausführen und Löschfolgen sichtbar machen; Tests belegen Handleranpassung, konfliktbedingten Gesamtabbruch und Erhalt vorhandenen Codes. +- [x] 2.1 Properties Bar mit F2-/F10-Fokus und typisierter Propertybearbeitung implementieren; Tests prüfen gültige/ungültige Werte, sichtbare Koordinaten und fehlende Designer-Statuszeile. +- [x] 2.2 Control-Cut/Copy/Paste/Clear und Undo über gemeinsame Dokumenttransaktionen umsetzen; Tests belegen Containerkinder, konfliktfreie Namen/Array-Indizes und vollständige Rücknahme einer Mehrfachaktion. +- [x] 2.3 Den Menüdesigner für Caption, CtlName, Tag, Index, Flags, Shortcut, Ebenen und Reihenfolge implementieren; ein FRM-Roundtrip prüft Access-Keys, Separatoren und indizierte Untermenüs. +- [x] 2.4 Color Palette und Designer-Window-Befehle anbinden; Eventtests prüfen Fore-/BackColor-Auftrag, Werkzeugfenster und Formauswahl. +- [x] 2.5 Form-/Control-Umbenennung über gebundene Codeverweise transaktional ausführen und Löschfolgen sichtbar machen; Tests belegen Handleranpassung, konfliktbedingten Gesamtabbruch und Erhalt vorhandenen Codes. ## 3. Ereignisse und Dateivertrag -- [ ] 3.1 F12/Event Procedures mit vorhandenen Eventsignaturen an die Prozedurerzeugung aus 03 binden; Tests prüfen Wiederaufruf ohne Duplikat, BYREF-/Typ- und Control-Array-Indexparameter. -- [ ] 3.2 Shift+F12/View→Form und Codewechsel an dieselbe Dokumentidentität binden; eine Eingabefolge belegt unveränderte ungespeicherte Struktur und Cursor-/Controlauswahl. -- [ ] 3.3 Save/Reopen und Binärimport→Textziel für gestaltete Formulare prüfen; bestehender FRM-Parser und CLI-Compiler müssen Struktur, Array-Indizes, Properties und Code gemeinsam akzeptieren. -- [ ] 3.4 Designerbedienung dokumentieren und alle Spec-Szenarien in verification.md zuordnen; IDE-/FRM-/Forms-Metadaten-Tests und Formatprüfung müssen bestehen. +- [x] 3.1 F12/Event Procedures mit vorhandenen Eventsignaturen an die Prozedurerzeugung aus 03 binden; Tests prüfen Wiederaufruf ohne Duplikat, BYREF-/Typ- und Control-Array-Indexparameter. +- [x] 3.2 Shift+F12/View→Form und Codewechsel an dieselbe Dokumentidentität binden; eine Eingabefolge belegt unveränderte ungespeicherte Struktur und Cursor-/Controlauswahl. +- [x] 3.3 Save/Reopen und Binärimport→Textziel für gestaltete Formulare prüfen; bestehender FRM-Parser und CLI-Compiler müssen Struktur, Array-Indizes, Properties und Code gemeinsam akzeptieren. +- [x] 3.4 Designerbedienung dokumentieren und alle Spec-Szenarien in verification.md zuordnen; IDE-/FRM-/Forms-Metadaten-Tests und Formatprüfung müssen bestehen. diff --git a/openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/verification.md b/openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/verification.md new file mode 100644 index 0000000..e7c000c --- /dev/null +++ b/openspec/changes/archive/2026-09-06-phase-5-05-formular-designer/verification.md @@ -0,0 +1,81 @@ +# Verification Report: phase-5-05-formular-designer + +Stand: 2026-09-06. Geprüft wurden Proposal, Design, alle zwölf Tasks und alle +sechs Anforderungen samt Szenarien gegen den aktuellen Arbeitsstand. + +## Ergebnis + +| Dimension | Ergebnis | +| --- | --- | +| Completeness | 12/12 Aufgaben; 6/6 Anforderungen umgesetzt | +| Correctness | Alle sechs Spec-Szenarien durch ausführbare Prüfungen abgedeckt | +| Coherence | Gemeinsames FRM-Dokument, vorhandene Metadaten, FormsModel/TextScreen, gemeinsamer Prozedur- und Undo-Pfad | +| Offene Befunde | 0 CRITICAL, 0 WARNING, 0 SUGGESTION | + +## Anforderungen und Szenarien + +Die Designerprüfungen liegen in `crates/tb-ide/tests/designer.rs` und bedienen +die produktiven App-Aktionen sowie den realen Event-Dispatcher/Renderer. + +| Anforderung / Scenario | Implementierung | Nachweis | +| --- | --- | --- | +| Designfläche und vollständige Toolbox / Jede Controlklasse | `designer::tools`, `design_place`, `preview`, `design_mouse`, `design_render`; `commands::menus` | `all_metadata_tools_persist_and_preview_matches_runtime_without_events`: alle 17 Toolboxvarianten, einschließlich VSpin/HSpin, Browserklassen und Timer, persistieren mit gleichen wirksamen Properties. Pixelvergleich mit FormsModel-Rendering; aktivierter 1-ms-Timer bleibt in der Vorschau ohne Deadline/Ereignis, Load-/Timer-Code wird nicht ausgeführt. `toolbox_double_click_tool_drag_grid_and_window_form_selection` prüft Doppelklick und Tools-Aufziehen. Menu wird durch die Menütests abgedeckt. | +| Auswahl und Geometrie / Container und Mehrfachauswahl | `design_select`, `design_geometry`, `design_resize`, `design_tab`, `design_mouse`; öffentliche Laufzeitgeometrie `FormsModel::rect` | `container_multiselect_keyboard_drag_resize_and_undo`: tatsächliche Framekoordinaten, Ctrl+Klick, Tab/Shift+Tab, 1-/5-Zellen-Bewegungen, Shift-Skalierung, Drag und gemeinsames Undo. `all_eight_handles_resize_transactionally_and_preserve_identity_after_structure_edits`: alle acht Handles, Ablehnung unzulässiger Geometrie und stabile IDs über Indexänderung, Löschen, Neuanlage und Undo. | +| Properties Bar und Bearbeitungsaktionen / Ungültige Eigenschaft | `design_property`, `design_value_focus`, `design_bar`, `design_copy/paste/delete`; `Project::edit_form/undo` | `property_bar_types_readonly_and_no_status_line`: Typen/Bereiche/readonly, F2/F10, sichtbare Geometrie, keine Statuszeile, Spin-Größenvertrag. Runtime-Setter validieren zusätzlich kontextabhängige Propertyregeln in einer isolierten Vorschau. `clipboard_children_arrays_and_delete_preserve_code`: Containerkinder, neue Namen, erhaltene Array-Indizes, Cut/Paste/Clear und vollständiges Undo. | +| Menüdesigner und Farbpalette / Menü mit Array und Untermenü | `design_action`, `design_submit`, `design_menu_move/commit`, `design_tool_render`; `forms::menu_shortcuts` | `menu_hierarchy_arrays_shortcuts_and_palette_use_events`: Access-Key, Separator, Index=0, Shortcut, Hierarchie/Reihenfolge, FRM-Roundtrip und Kompilierung; Fore-/BackColor-Auftrag. `menu_indent_outdent_rename_and_field_changes_have_one_undo`: Ebenenwechsel und eine Undo-Einheit für Name und Dialogfelder. `property_menu_buttons_palette_drag_and_modal_mouse_are_connected`: echte Property-/Menübuttons, Farbdrop und Modalität. | +| Dokumentkonsistenz und Umbenennen / Umbenennen mit Ereigniscode | `design_rename_with`; `ProjectCompiler::bound_form_references`; opt-in Referenzaufzeichnung im regulären Sema; `Project::commit_transaction/undo` | `bound_rename_across_documents_is_atomic_and_undo_restores_all`: Control, Handler und qualifizierte Verweise in anderen Modulen; Undo aus einer beteiligten Datei; Konflikt-/Syntaxabbruch ohne Teiländerung. `rename_preserves_shadowed_names_and_handles_form_aliases_and_include_calls`, `shared_include_ambiguity_aborts_rename_and_event_defaults_use_includes`, `imported_type_shadow_is_not_an_object_reference_during_rename`: Kommentare/Strings, gleichnamige Variablen, importierte UDT-Felder, Prototypen/Aufrufe, Formularalias und mehrdeutig gemeinsam genutzte Includes. Löschen erhält Code und meldet ungebundene Handler sichtbar. | +| Ereignisprozeduren und Moduswechsel / Ereignis zweimal öffnen | `design_event`, `event_parameters`, gemeinsames `App::new_procedure_text`; `show_document/design_enter` | `clipboard_children_arrays_and_delete_preserve_code` öffnet Array-Click zweimal ohne Duplikat. `event_array_signature_twice_and_shared_form_identity` prüft außerdem DragOver zweimal mit Index/CONTROL/SINGLE/INTEGER, gleiche Dokumentidentität, Cursor und Controlauswahl. `existing_default_typed_event_and_all_metadata_signatures_compile`: vorhandene DEFtype-Signatur und alle CommandButton-Events kompilieren. `events_with_the_same_name_are_local_to_their_form`: gleichnamige Ereignisse zweier Formulare bleiben getrennt. | + +## Persistenz und Compilergrenze + +- `binary_designer_import_requires_text_target_and_compiles` bearbeitet einen + echten Binärimport, verlangt ein separates Textziel, erhält die Originalbytes + und lädt das Ergebnis über den bestehenden Compilerpfad. +- `crates/tb-cli/tests/project.rs::designer_frm_is_accepted_by_the_standalone_cli_compiler` + erzeugt ein Formular samt Control-Array und Ereigniscode über App-Aktionen, + speichert es mit dem vorhandenen Writer, startet den echten `tbc build`-Prozess + und lädt das erzeugte TBC erneut. Die zusätzliche Abhängigkeit auf tb-ide + gilt ausschließlich für CLI-Tests; es gibt keine neue externe Bibliothek. +- Der FRM-Writer lässt explizite Defaultwerte weg. Die Roundtripprüfung + vergleicht Klassen, Namen, Hierarchie, explizite Array-Indizes und sämtliche + wirksamen Metadatenproperties, statt unterschiedliche interne Maps als + Formatfehler zu behandeln. + +## Behobene Befunde während der Verifikation + +- Acht Größenhandles einschließlich linker/oberer Kanten und zusammenfallender + Handles kleiner Controls werden korrekt aufgelöst. +- Designerzeichen erreichen den Quelltexteditor nicht. Property-/Menübuttons + sind im nichtmodalen Dispatcher angebunden; Dialoge sperren Hintergrundaktionen. +- Fenster-/Formwechsel aktualisieren die Designeridentität. Die ursprüngliche + Befehlsidentität beim Weiterschalten eines Fensters bleibt erhalten. +- Positions-/Größenanzeige bleibt rechts sichtbar, auch wenn Propertytext lang ist. +- Umbenennen nutzt die Importkontexte der tatsächlich übersetzten Module. + Synthetische Importprototypen ohne physische Quellposition werden nicht editiert. + Echte Prototypen und Prozeduraufrufe werden semantisch erfasst; keine globale + Bezeichnerersetzung beschädigt unabhängige Variablen/UDT-Felder. +- Ereigniserzeugung verwendet denselben includebewussten New-Sub-Pfad wie der + Editor. Vorhandene Formularalias-/DEFtype-Signaturen und gleichnamige Events + unterschiedlicher Formulare werden korrekt zugeordnet. +- Aktivierte Timer bleiben im Dokument erhalten, sind in der Vorschau aber + ausdrücklich deaktiviert. Es existiert dort keine VM oder Event-Pump. + +- Der workspaceweite Clippy-Lauf fand die bereits vorhandene Shell-Hilfsfunktion + hinter dem Testmodul in `tb-runtime/src/host.rs`. Sie wurde unverändert vor + das Testmodul verschoben; das beseitigt die Ordnungswarnung ohne Verhaltensänderung. + +## Abschlussprüfungen + +- `cargo test --workspace`: 567 bestanden, 0 fehlgeschlagen, 2 bestehende + Ignore-Fälle (Golden-Output-Generator und externe VBDOS-Referenzsuite). + Darin: 17 Designer-Integrationstests und der zusätzliche echte CLI-Compilerpfad. +- `cargo clippy --workspace --all-targets -- -D warnings`: bestanden. +- `cargo fmt --all -- --check`: bestanden. +- `git diff --check`: bestanden. +- `openspec validate --all --strict`: 23/23 gültig. Vorhandene INFO-Hinweise + zur Länge von Requirement-Texten sind keine Validierungsfehler. + +Keine angeforderte Prüfdimension wurde ausgelassen. Die Tests sind headless; +für diesen Change wurde keine interaktive GUI-/Emulatorsitzung benötigt. +Die gemeinsame Phase-5-Abnahme bleibt Aufgabe von Change 08. Der Change ist +vollständig umgesetzt und zur separaten Synchronisierung/Archivierung bereit. 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 f41582a..6a42c4a 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](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../archive/2026-09-06-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](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../archive/2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../archive/2026-09-06-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-formulardesigner/spec.md b/openspec/specs/ide-formulardesigner/spec.md new file mode 100644 index 0000000..204189d --- /dev/null +++ b/openspec/specs/ide-formulardesigner/spec.md @@ -0,0 +1,49 @@ +# ide-formulardesigner Specification + +## Purpose + +Ermöglicht das visuelle Erstellen und Bearbeiten der bestehenden Formular- und Controlmodelle mit gemeinsamem Ereigniscode und kompatibler FRM-Persistenz. + +## Requirements + +### Requirement: Designfläche und vollständige Toolbox +Der Designer SHALL Formularfläche, Toolbox und gleichwertiges Tools-Menü anbieten. Alle in den bestehenden Forms-Metadaten unterstützten Designzeit-Controlklassen SHALL platzierbar sein, einschließlich Datei-/Verzeichnis-/Laufwerkslisten, Timer, Container, Scroll- und Spin-Controls. Werkzeugwahl mit Aufziehen und Toolbox-Doppelklick SHALL neue Controls mit gültigen Defaults und eindeutigen Namen erzeugen. Die Designvorschau MUST NOT Ereigniscode oder Laufzeittimer ausführen. + +#### Scenario: Jede Controlklasse +- **WHEN** jede unterstützte Designzeitklasse einmal aus Toolbox oder Tools eingefügt wird +- **THEN** entsteht ein gültiger Eintrag im Formulardokument und nach Speichern/Wiederöffnen dieselbe Klasse mit ihren Eigenschaften + +### Requirement: Auswahl und Geometrie +Ausgewählte Controls SHALL Sizing Handles zeigen. Drag SHALL verschieben und Handles SHALL skalieren; Pfeile SHALL um eine Zelle, Ctrl+Pfeile um fünf Zellen und Shift+Pfeile die Größe ändern. Tab/Shift+Tab SHALL die Controlauswahl wechseln, Ctrl+Klick SHALL eine Mehrfachauswahl bilden, Grid Lines SHALL das Raster umschalten. Koordinaten SHALL sich auf den tatsächlichen Container beziehen und ungültige Größen SHALL ohne Dokumentbeschädigung abgewiesen werden. + +#### Scenario: Container und Mehrfachauswahl +- **WHEN** mehrere Controls innerhalb eines Frame ausgewählt und per Tastatur verschoben werden +- **THEN** ändern sich ihre relativen Positionen um denselben Betrag, andere Controls bleiben unverändert und Undo stellt die gesamte Aktion zurück + +### Requirement: Properties Bar und Bearbeitungsaktionen +Die Properties Bar SHALL Property-/Value-Auswahl, Spalte/Zeile und Breite/Höhe anzeigen. F2 SHALL die Value-Box fokussieren und F10 zwischen Menüleiste und Properties Bar wechseln. Im Designer SHALL keine Statuszeile erscheinen. Edit→Cut/Copy/Paste/Clear/Undo SHALL auf die ausgewählten Controls wirken und beim Einfügen gültige Namen, Eltern und Array-Indizes herstellen. Klassenabhängige Propertytypen, Wertebereiche und schreibgeschützte Eigenschaften SHALL berücksichtigt werden. + +#### Scenario: Ungültige Eigenschaft +- **WHEN** eine Eigenschaft außerhalb ihres gültigen Bereichs oder mit falschem Typ eingegeben wird +- **THEN** bleibt ihr alter Wert erhalten und die Fehlermeldung benennt Control und Eigenschaft + +### Requirement: Menüdesigner und Farbpalette +Menu Design Window SHALL Caption mit Access-Key/Separator, CtlName, Tag, Index, Checked, Enabled, Visible, Separator und unterstützte Shortcuts bearbeiten. Einfügen, Löschen, Ebene und Reihenfolge SHALL die Menühierarchie konsistent ändern. Color Palette SHALL ForeColor/BackColor und das Auftragen auf die Form unterstützen. Window SHALL Toolbox, Color Palette, Menu Design Window, Help und die Formliste bedienen. + +#### Scenario: Menü mit Array und Untermenü +- **WHEN** ein Untermenü mit Access-Key und ein indiziertes Menüelement erstellt, umgeordnet und gespeichert werden +- **THEN** bleiben Hierarchie, Reihenfolge, Index, Shortcut und Darstellung nach Wiederöffnen und Kompilieren erhalten + +### Requirement: Dokumentkonsistenz und Umbenennen +Designer und Code SHALL dasselbe FRM-Dokument ändern. Änderungen an Formularstruktur, Properties und Code SHALL gemeinsam gespeichert und rückgängig gemacht werden können. Control-/Form-Umbenennen SHALL gebundene Ereignisnamen und eindeutig auflösbare Codeverweise anpassen; Namenskonflikte oder nicht sicher auflösbare betroffene Verweise SHALL die Umbenennung ohne Teiländerung verhindern. Löschen SHALL vorhandenen Code nicht stillschweigend entfernen. + +#### Scenario: Umbenennen mit Ereigniscode +- **WHEN** ein Button mit Click-Prozedur umbenannt wird +- **THEN** sind Control, Handler und eindeutig gebundene Verweise konsistent umbenannt; Undo stellt alle betroffenen Teile gemeinsam wieder her + +### Requirement: Ereignisprozeduren und Moduswechsel +F12/Event Procedures SHALL Objekt und zulässiges Ereignis wählen und die vorhandene passende Prozedur öffnen oder genau eine Prozedur mit der dokumentierten Signatur erzeugen. Control-Arrays SHALL die richtigen Indexparameter erhalten. Shift+F12/View→Form SHALL zum zugehörigen Formular und Control zurückkehren. Der Wechsel MUST ungespeicherte Änderungen erhalten. + +#### Scenario: Ereignis zweimal öffnen +- **WHEN** das Click-Ereignis eines Control-Arrays zweimal über F12 geöffnet wird +- **THEN** existiert genau eine signaturgerechte Prozedur mit Indexparameter und beide Aufrufe führen dorthin