Implement and archive Phase 5 form designer

This commit is contained in:
2026-09-06 20:19:57 +02:00
parent e3dc9028bf
commit ce97700a98
27 changed files with 3625 additions and 167 deletions

1
Cargo.lock generated
View File

@@ -591,6 +591,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"tb-frontend", "tb-frontend",
"tb-ide",
"tb-runtime", "tb-runtime",
"tb-ui", "tb-ui",
"tb-vm", "tb-vm",

View File

@@ -16,3 +16,6 @@ tb-vm.workspace = true
tb-runtime.workspace = true tb-runtime.workspace = true
tb-ui = { workspace = true, features = ["terminal"] } tb-ui = { workspace = true, features = ["terminal"] }
anyhow.workspace = true anyhow.workspace = true
[dev-dependencies]
tb-ide = { path = "../tb-ide" }

View File

@@ -234,3 +234,38 @@ fn explicit_startup_order_and_form_selection_survive_tbc() {
} }
std::fs::remove_dir_all(dir).unwrap(); 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();
}

View File

@@ -751,3 +751,17 @@ impl FormCatalog {
false false
} }
} }
/// Tastenkombinationen, die der Forms-Host für Menü-Shortcuts liefert.
pub fn menu_shortcuts() -> Vec<String> {
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
}

View File

@@ -421,6 +421,23 @@ pub fn lower_with_forms(
(hir, s.diags) (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<Diagnostic>,
}
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 { fn new_sema(module: &Module, catalog: &FormCatalog) -> Sema {
Sema { Sema {
diags: Vec::new(), diags: Vec::new(),
@@ -446,6 +463,7 @@ fn new_sema(module: &Module, catalog: &FormCatalog) -> Sema {
forms: catalog.clone(), forms: catalog.clone(),
module_name: module.name.clone(), module_name: module.name.clone(),
event_procs: Vec::new(), event_procs: Vec::new(),
references: None,
} }
} }
@@ -550,6 +568,7 @@ struct Sema {
forms: FormCatalog, forms: FormCatalog,
module_name: String, module_name: String,
event_procs: Vec<hir::HEventProc>, event_procs: Vec<hir::HEventProc>,
references: Option<BoundFormReferences>,
} }
impl Sema { impl Sema {
@@ -942,6 +961,9 @@ impl Sema {
{ {
return; 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(); let mut expected: Vec<(&str, forms::EventParamType)> = Vec::new();
if self.forms.objects[object as usize].array { if self.forms.objects[object as usize].array {
expected.push(("INDEX", forms::EventParamType::Integer)); expected.push(("INDEX", forms::EventParamType::Integer));
@@ -1049,6 +1071,14 @@ impl Sema {
) -> Option<(u16, forms::FormObject)> { ) -> Option<(u16, forms::FormObject)> {
if let Some(parent) = parent { if let Some(parent) = parent {
if let Some((id, object)) = self.forms.find_in(name, 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())); return Some((id, object.clone()));
} }
self.err( self.err(
@@ -1067,7 +1097,11 @@ impl Sema {
pos: SourcePos, pos: SourcePos,
) -> Option<(u16, crate::forms::FormObject)> { ) -> Option<(u16, crate::forms::FormObject)> {
if let Some((id, object)) = self.find_object(name) { 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}'")); self.err(pos, format!("Unknown object '{name}'"));
None None
@@ -2391,7 +2425,11 @@ impl Sema {
OptionKind::Base(b) => self.option_base = *b, OptionKind::Base(b) => self.option_base = *b,
}, },
Stmt::TypeDecl { .. } => {} // bereits in Pass 1 registriert 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 { Stmt::Call {
name, args, pos, .. name, args, pos, ..
} => { } => {
@@ -3377,6 +3415,9 @@ impl Sema {
pos: SourcePos, pos: SourcePos,
out: &mut Vec<HStmt>, out: &mut Vec<HStmt>,
) { ) {
if let Some(r) = &mut self.references {
r.procedures.push((pos, name.into()));
}
if name == "CLIPBOARD.ADDITEM" { if name == "CLIPBOARD.ADDITEM" {
if args.len() != 1 { if args.len() != 1 {
self.err(pos, "Argument-count mismatch for CLIPBOARD.ADDITEM"); self.err(pos, "Argument-count mismatch for CLIPBOARD.ADDITEM");
@@ -4834,6 +4875,9 @@ impl Sema {
.map(|(id, info)| (id, info.clone())) .map(|(id, info)| (id, info.clone()))
{ {
if args.is_none() || info.array { 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 { let Ok(index) = self.object_index(object, args, scope, pos) else {
return (HExpr::Int(0), Ty::Unknown); return (HExpr::Int(0), Ty::Unknown);
}; };

View File

@@ -30,6 +30,9 @@ pub enum Execution {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindowKind { pub enum WindowKind {
Toolbox,
Palette,
MenuDesign,
Code(ViewId), Code(ViewId),
Project, Project,
Output, Output,
@@ -108,7 +111,7 @@ impl Field {
pub fn flag(&self) -> bool { pub fn flag(&self) -> bool {
matches!(self.value, FieldValue::Toggle(true)) matches!(self.value, FieldValue::Toggle(true))
} }
fn key(&mut self, key: KeyEvent) { pub(crate) fn key(&mut self, key: KeyEvent) {
match &mut self.value { match &mut self.value {
FieldValue::Toggle(value) => { FieldValue::Toggle(value) => {
if matches!(key.code, K::Char(' ') | K::Left | K::Right) { if matches!(key.code, K::Char(' ') | K::Left | K::Right) {
@@ -189,6 +192,11 @@ pub enum AfterSave {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum DialogKind { pub enum DialogKind {
DesignProperty,
DesignPalette,
DesignMenu((String, Option<i32>)),
DesignObject(Vec<u64>),
DesignEvent(u64),
Search { Search {
view: ViewId, view: ViewId,
selection: Option<std::ops::Range<usize>>, selection: Option<std::ops::Range<usize>>,
@@ -251,6 +259,10 @@ impl Dialog {
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum Hit { pub enum Hit {
DesignTool(usize),
DesignPaint(&'static str, u8),
DesignObject(u64),
DesignAction(&'static str),
Command(Command), Command(Command),
Menu(usize), Menu(usize),
MenuItem(usize), MenuItem(usize),
@@ -263,6 +275,7 @@ pub enum Hit {
} }
pub struct App { pub struct App {
pub designer: crate::designer::Designer,
pub session: crate::execution::Session, pub session: crate::execution::Session,
pub editor: crate::editor::Editor, pub editor: crate::editor::Editor,
pub project: Project, pub project: Project,
@@ -301,6 +314,7 @@ impl App {
let (options, errors, config_disk) = Options::load(&config_path); let (options, errors, config_disk) = Options::load(&config_path);
project.include_paths = options.include_paths.clone(); project.include_paths = options.include_paths.clone();
let mut app = Self { let mut app = Self {
designer: Default::default(),
session: Default::default(), session: Default::default(),
editor: Default::default(), editor: Default::default(),
project, project,
@@ -389,6 +403,9 @@ impl App {
pub fn active_document(&self) -> Option<DocumentId> { pub fn active_document(&self) -> Option<DocumentId> {
match self.active_window()?.kind { match self.active_window()?.kind {
WindowKind::Code(v) => self.project.view(v).ok().map(|v| v.document()), 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(), _ => self.project.members().get(self.selected_member).copied(),
} }
} }
@@ -477,7 +494,8 @@ impl App {
.into(), .into(),
); );
} }
if matches!( if !(self.mode == Mode::Designer && matches!(command, Cut | Copy | Paste | Clear))
&& matches!(
command, command,
LoadText LoadText
| Print | Print
@@ -497,10 +515,12 @@ impl App {
| Procedures | Procedures
| PreviousCode | PreviousCode
| Diagnostics | Diagnostics
) && !matches!( )
&& !matches!(
self.active_window().map(|w| w.kind), self.active_window().map(|w| w.kind),
Some(WindowKind::Code(_)) Some(WindowKind::Code(_))
) { )
{
return Some("Kein Codefenster aktiv".into()); return Some("Kein Codefenster aktiv".into());
} }
if matches!( if matches!(
@@ -540,9 +560,19 @@ impl App {
self.control_menu = false; self.control_menu = false;
if let Err(e) = self.action(command) { if let Err(e) = self.action(command) {
self.message = format!("{e:#}"); 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<()> { fn action(&mut self, command: Command) -> Result<()> {
if self.designer_command(command)? {
return Ok(());
}
use Command::*; use Command::*;
let id = self.active_document(); let id = self.active_document();
match command { match command {
@@ -742,6 +772,18 @@ impl App {
FocusWindow(id) => { FocusWindow(id) => {
if self.windows.iter().any(|w| w.id == id) { if self.windows.iter().any(|w| w.id == id) {
self.active = 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 => { Calls | Debug | HelpWindow | Immediate | Output | Project => {
@@ -930,6 +972,22 @@ impl App {
}; };
self.properties = form; self.properties = form;
self.value_focus = false; 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(()) Ok(())
} }
fn cycle(&mut self, direction: isize) { fn cycle(&mut self, direction: isize) {
@@ -942,7 +1000,9 @@ impl App {
.position(|w| w.id == self.active) .position(|w| w.id == self.active)
.unwrap_or(0); .unwrap_or(0);
let i = (i as isize + direction).rem_euclid(self.windows.len() as isize) as usize; 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) { fn arrange(&mut self) {
let area = self.area(); let area = self.area();
@@ -997,6 +1057,7 @@ impl App {
} }
} }
self.session = Default::default(); self.session = Default::default();
self.designer = Default::default();
self.basic_events.clear(); self.basic_events.clear();
self.execution = Execution::Idle; self.execution = Execution::Idle;
self.base = self.project.directory().to_path_buf(); self.base = self.project.directory().to_path_buf();
@@ -1072,6 +1133,14 @@ impl App {
} }
fn submit(&mut self, d: &mut Dialog) -> Result<bool> { fn submit(&mut self, d: &mut Dialog) -> Result<bool> {
match d.kind.clone() { match d.kind.clone() {
DialogKind::DesignProperty
| DialogKind::DesignPalette
| DialogKind::DesignMenu(_)
| DialogKind::DesignObject(_)
| DialogKind::DesignEvent(_) => {
self.design_submit(d)?;
return Ok(false);
}
DialogKind::Search { .. } DialogKind::Search { .. }
| DialogKind::Procedure(_) | DialogKind::Procedure(_)
| DialogKind::Procedures(_) | DialogKind::Procedures(_)
@@ -1350,6 +1419,19 @@ impl App {
} }
self.key(key); self.key(key);
} else if let Event::Mouse(mouse) = event { } 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.dialog.is_none() && self.menu.is_none() && self.program_focus() {
if self.session.fullscreen { if self.session.fullscreen {
self.basic_events.push(Event::Mouse(mouse)); self.basic_events.push(Event::Mouse(mouse));
@@ -1453,12 +1535,29 @@ impl App {
return; return;
} }
match hit { 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::Command(c) => self.execute(c),
Hit::Menu(i) => { Hit::Menu(i) => {
self.properties = false; self.properties = false;
self.menu = Some((i, 0)); self.menu = Some((i, 0));
} }
Hit::Window(id) => self.active = id, Hit::Window(id) => self.execute(Command::FocusWindow(id)),
Hit::ProjectMember(i) => { Hit::ProjectMember(i) => {
self.selected_member = i; self.selected_member = i;
if let Some(w) = self.windows.iter().find(|w| w.kind == WindowKind::Project) { if let Some(w) = self.windows.iter().find(|w| w.kind == WindowKind::Project) {
@@ -1674,19 +1773,61 @@ impl App {
return; return;
} }
if self.mode == Mode::Designer && key.code == K::F(2) { if self.mode == Mode::Designer && key.code == K::F(2) {
self.properties = true; if let Err(e) = self.design_value_focus() {
self.value_focus = true; self.message = format!("{e:#}");
self.message = "Properties Value · Bearbeitung folgt in Change 05".into(); }
return; return;
} }
if self.program_focus() && key.modifiers.contains(M::CONTROL) && key.code == K::Char('c') { if self.program_focus() && key.modifiers.contains(M::CONTROL) && key.code == K::Char('c') {
self.pause_execution(); self.pause_execution();
return; 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) { if let Some(command) = shortcut(key) {
self.execute(command); self.execute(command);
return; 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() { if self.program_focus() {
self.basic_events.push(Event::Key(key)); self.basic_events.push(Event::Key(key));
return; return;
@@ -1783,7 +1924,7 @@ impl App {
)) ))
} }
fn code_key(&mut self, key: KeyEvent) -> Result<()> { 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(()); return Ok(());
} }
self.editor_key(key) self.editor_key(key)
@@ -1803,7 +1944,7 @@ impl App {
let mut entries = menus[index] let mut entries = menus[index]
.items .items
.iter() .iter()
.map(|i| (i.label.into(), i.command)) .map(|i| (i.label.clone(), i.command))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if menus[index].title == "Window" { if menus[index].title == "Window" {
entries.extend( entries.extend(

View File

@@ -101,7 +101,6 @@ impl Command {
pub fn feature_phase(self) -> Option<u8> { pub fn feature_phase(self) -> Option<u8> {
use Command::*; use Command::*;
match self { match self {
Events | Grid | Palette | MenuDesign | Toolbox | Tool(_) => Some(5),
NextStatement | AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches NextStatement | AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches
| Trace | History | Breakpoint | ClearBreakpoints | BreakErrors | SetStatement | Trace | History | Breakpoint | ClearBreakpoints | BreakErrors | SetStatement
| RunToCursor | Step | ProcedureStep | HistoryBack | HistoryForward => Some(6), | RunToCursor | Step | ProcedureStep | HistoryBack | HistoryForward => Some(6),
@@ -113,7 +112,7 @@ impl Command {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Item { pub struct Item {
/// & markiert das sichtbare Mnemonic, … einen Dialog. /// & markiert das sichtbare Mnemonic, … einen Dialog.
pub label: &'static str, pub label: String,
pub command: Option<Command>, pub command: Option<Command>,
} }
impl Item { impl Item {
@@ -136,12 +135,12 @@ pub struct Menu {
} }
pub fn menus(designer: bool) -> Vec<Menu> { pub fn menus(designer: bool) -> Vec<Menu> {
use Command::*; use Command::*;
let item = |label, command| Item { let item = |label: &str, command| Item {
label, label: label.into(),
command: Some(command), command: Some(command),
}; };
let sep = || Item { let sep = || Item {
label: "────────", label: "────────".into(),
command: None, command: None,
}; };
let menu = |title, mnemonic, items| Menu { let menu = |title, mnemonic, items| Menu {
@@ -211,27 +210,30 @@ pub fn menus(designer: bool) -> Vec<Menu> {
result[2] result[2]
.items .items
.extend([sep(), item("&Menu Bar", MenuBar), item("&Grid Lines", Grid)]); .extend([sep(), item("&Menu Bar", MenuBar), item("&Grid Lines", Grid)]);
result.push(menu( result.push(menu("Tools", 't', {
"Tools", let mut used = std::collections::BTreeSet::new();
't', crate::designer::tools()
vec![ .into_iter()
item("&Check Box", Tool("CheckBox")), .map(|(name, _, _)| {
item("C&ombo Box", Tool("ComboBox")), let mut label = name.to_owned();
item("Command &Button", Tool("CommandButton")), if let Some((at, c)) = name
item("&Dir List", Tool("DirListBox")), .char_indices()
item("D&rive List", Tool("DriveListBox")), .find(|(_, c)| !used.contains(&c.to_ascii_lowercase()))
item("&File List", Tool("FileListBox")), {
item("Fr&ame", Tool("Frame")), used.insert(c.to_ascii_lowercase());
item("&HScrollBar", Tool("HScrollBar")), label.insert(at, '&');
item("&Label", Tool("Label")), } else {
item("L&ist Box", Tool("ListBox")), let c = ('1'..='9').find(|c| !used.contains(c)).unwrap();
item("O&ption Button", Tool("OptionButton")), used.insert(c);
item("Pict&ure Box", Tool("PictureBox")), label = format!("&{c} {label}");
item("&Text Box", Tool("TextBox")), }
item("Ti&mer", Tool("Timer")), Item {
item("&VScrollBar", Tool("VScrollBar")), label,
], command: Some(Tool(name)),
)); }
})
.collect()
}));
} else { } else {
result.extend([ result.extend([
menu( menu(

File diff suppressed because it is too large Load Diff

View File

@@ -51,6 +51,8 @@ impl View {
#[derive(Debug)] #[derive(Debug)]
struct Edit { struct Edit {
content: Content, content: Content,
design_ids: BTreeMap<(String, Option<i32>), u64>,
group: Option<(u64, Vec<DocumentId>)>,
views: Vec<(ViewId, View)>, views: Vec<(ViewId, View)>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -64,6 +66,7 @@ pub struct Document {
binary_source: Option<PathBuf>, binary_source: Option<PathBuf>,
warnings: Vec<frm::BinaryWarning>, warnings: Vec<frm::BinaryWarning>,
undo: Vec<Edit>, undo: Vec<Edit>,
design_ids: BTreeMap<(String, Option<i32>), u64>,
} }
impl Document { impl Document {
pub fn path(&self) -> Option<&Path> { pub fn path(&self) -> Option<&Path> {
@@ -257,6 +260,7 @@ impl Project {
binary_source: read.binary.then_some(path), binary_source: read.binary.then_some(path),
warnings: read.warnings, warnings: read.warnings,
undo: Vec::new(), undo: Vec::new(),
design_ids: BTreeMap::new(),
}, },
); );
Ok(id) Ok(id)
@@ -308,6 +312,7 @@ impl Project {
binary_source: None, binary_source: None,
warnings: Vec::new(), warnings: Vec::new(),
undo: Vec::new(), undo: Vec::new(),
design_ids: BTreeMap::new(),
}, },
); );
self.manifest.lines.push(ProjectLine::File(path)); self.manifest.lines.push(ProjectLine::File(path));
@@ -344,6 +349,14 @@ impl Project {
properties: BTreeMap::new(), properties: BTreeMap::new(),
children: Vec::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( self.create(
name, name,
Content::Form(Box::new(FormFile::new("1.00", root, ""))), Content::Form(Box::new(FormFile::new("1.00", root, ""))),
@@ -423,7 +436,82 @@ impl Project {
pub fn view_code(&self, id: ViewId) -> Result<&str> { pub fn view_code(&self, id: ViewId) -> Result<&str> {
Ok(self.document(self.view(id)?.document)?.code()) Ok(self.document(self.view(id)?.document)?.code())
} }
pub fn design_ids(&mut self, id: DocumentId) -> Result<&BTreeMap<(String, Option<i32>), 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<i32>)>) {
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<i32>),
new: (String, Option<i32>),
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<i32>), 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<()> { fn commit_edit(&mut self, id: DocumentId, content: Content) -> Result<()> {
self.design_ids(id)?;
let old = self.document(id)?; let old = self.document(id)?;
if old.content == content { if old.content == content {
return Ok(()); return Ok(());
@@ -456,6 +544,8 @@ impl Project {
}; };
let undo = Edit { let undo = Edit {
content: old.content.clone(), content: old.content.clone(),
design_ids: old.design_ids.clone(),
group: None,
views: self views: self
.views .views
.iter() .iter()
@@ -546,12 +636,31 @@ impl Project {
self.commit_edit(id, Content::Form(form)) self.commit_edit(id, Content::Form(form))
} }
pub fn undo(&mut self, id: DocumentId) -> Result<bool> { pub fn undo(&mut self, id: DocumentId) -> Result<bool> {
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<bool> {
self.document(id)?; self.document(id)?;
let doc = self.documents.get_mut(&id).unwrap(); let doc = self.documents.get_mut(&id).unwrap();
let Some(edit) = doc.undo.pop() else { let Some(edit) = doc.undo.pop() else {
return Ok(false); return Ok(false);
}; };
doc.content = edit.content; doc.content = edit.content;
doc.design_ids = edit.design_ids;
doc.revision += 1; doc.revision += 1;
for (id, view) in edit.views { for (id, view) in edit.views {
if let Some(current) = self.views.get_mut(&id) { if let Some(current) = self.views.get_mut(&id) {

View File

@@ -563,61 +563,14 @@ impl App {
let v = self.editor_view()?; let v = self.editor_view()?;
let view = self.project.view(v)?.clone(); let view = self.project.view(v)?.clone();
let id = view.document(); let id = view.document();
let code = self.project.document(id)?.code(); let addition = self.new_procedure_text(
let input = self.project.sources()?; id,
let name = d.fields[0].string(); view.cursor,
for unit in &input.units { &d.fields[0].string(),
let (module, _) = unit.parse(0, &mut Vec::new());
ensure!(
!module
.procs
.iter()
.any(|p| p.sig.name.eq_ignore_ascii_case(&name)),
"Prozedurname bereits im Projekt vorhanden"
);
}
let path = self
.project
.document(id)?
.source_path()
.display()
.to_string();
let unit = input
.units
.iter()
.find(|u| u.segments.iter().any(|s| s.file == path))
.ok_or_else(|| anyhow!("Keine Übersetzungseinheit"))?;
let first = match self.project.document(id)?.content() {
tb_vm::project_io::Content::Form(f) => {
tb_ui::frm::read_text(&path, &tb_ui::frm::write_text(f))?.code_line()
}
_ => 1,
};
let line =
first + code[..view.cursor].bytes().filter(|b| *b == b'\n').count() as u32;
let col = view.cursor - line_start(code, view.cursor);
let mut expanded = String::new();
let mut at = None;
for segment in &unit.segments {
for (i, text) in segment.text.split_inclusive('\n').enumerate() {
if segment.file == path && segment.first_line + i as u32 == line {
at = Some(
expanded.len() + col.min(text.trim_end_matches(['\n', '\r']).len()),
);
}
expanded.push_str(text);
if !text.ends_with('\n') {
expanded.push('\n');
}
}
}
let addition = tb_frontend::editing::new_procedure(
&expanded,
at.unwrap_or(expanded.len()),
&name,
function, function,
) false,
.map_err(|e| anyhow!(e))?; )?;
let code = self.project.document(id)?.code();
let end = code.len(); let end = code.len();
self.project.replace_text(id, end..end, &addition)?; self.project.replace_text(id, end..end, &addition)?;
self.project.view_mut(v)?.cursor = end + 1; self.project.view_mut(v)?.cursor = end + 1;
@@ -733,6 +686,75 @@ impl App {
self.message = format!("{} Treffer ersetzt", ranges.len()); self.message = format!("{} Treffer ersetzt", ranges.len());
self.editor_scroll() self.editor_scroll()
} }
pub(crate) fn new_procedure_text(
&self,
id: DocumentId,
cursor: usize,
name: &str,
function: bool,
module_local: bool,
) -> Result<String> {
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) { pub(crate) fn diagnostic_dialog(&mut self) {
if !self.revision_current() { if !self.revision_current() {
if let Err(e) = self.compile_current() { if let Err(e) = self.compile_current() {
@@ -840,6 +862,9 @@ impl App {
.then_some(self.editor.diagnostics.as_slice()) .then_some(self.editor.diagnostics.as_slice())
} }
pub(crate) fn editor_position(&self) -> Option<(ViewId, DocumentId, usize)> { 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 v = self.editor_view().ok()?;
let view = self.project.view(v).ok()?; let view = self.project.view(v).ok()?;
let code = self.project.document(view.document()).ok()?.code(); let code = self.project.document(view.document()).ok()?.code();

View File

@@ -10,3 +10,5 @@ pub mod terminal;
pub mod editor; pub mod editor;
pub mod execution; pub mod execution;
pub mod designer;

View File

@@ -147,15 +147,10 @@ impl App {
let v = self.project.view(view).unwrap(); let v = self.project.view(view).unwrap();
let doc = self.project.document(v.document()).unwrap(); let doc = self.project.document(v.document()).unwrap();
if self.mode == Mode::Designer if self.mode == Mode::Designer
&& active && Some(v.document()) == self.designer.document
&& matches!(doc.content(), tb_vm::project_io::Content::Form(_)) && matches!(doc.content(), tb_vm::project_io::Content::Form(_))
{ {
put( self.design_render(f, inner);
f,
inner,
"Formular · Designerwerkzeuge folgen in Change 05",
content_style,
);
} else { } else {
let expansion = self.editor.expansions.get(&view); let expansion = self.editor.expansions.get(&view);
let code = expansion.map(String::as_str).unwrap_or(doc.code()); let code = expansion.map(String::as_str).unwrap_or(doc.code());
@@ -334,6 +329,9 @@ impl App {
self.hits.push((r, Hit::ProjectMember(row))); self.hits.push((r, Hit::ProjectMember(row)));
} }
} }
kind @ (WindowKind::Toolbox | WindowKind::Palette | WindowKind::MenuDesign) => {
self.design_tool_render(f, kind, inner)
}
WindowKind::Output => { WindowKind::Output => {
f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), inner) f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), inner)
} }
@@ -400,22 +398,27 @@ impl App {
); );
} }
if self.mode == Mode::Designer && self.properties { if self.mode == Mode::Designer && self.properties {
let (left, geometry) = self.design_bar();
let width = (geometry.width() as u16 + 1).min(area.width);
put( put(
f, f,
Rect::new(0, 0, area.width, 1), Rect::new(0, 0, area.width - width, 1),
format!( left,
"Property: [Caption]↓ Value: […]↓ │ Spalte,Zeile │ BxH{}", style(self, 0),
if self.value_focus { );
" · Value aktiv" put(
} else { f,
"" Rect::new(area.width - width, 0, width, 1),
} geometry,
),
style(self, 0), style(self, 0),
); );
self.hits.push(( self.hits.push((
Rect::new(0, 0, area.width, 1), Rect::new(0, 0, area.width / 2, 1),
Hit::Command(Command::MenuBar), Hit::DesignAction("property"),
));
self.hits.push((
Rect::new(area.width / 2, 0, area.width - area.width / 2, 1),
Hit::DesignAction("value"),
)); ));
} else { } else {
put( put(

View File

@@ -140,7 +140,8 @@ fn start_snapshot_and_reference_menus_have_all_commands() {
let name = item.text(); let name = item.text();
assert!( assert!(
reference.contains(name.trim_end_matches('…')) 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}" "Nicht in Referenz: {name}"
); );
assert!(item.mnemonic().is_some()); assert!(item.mnemonic().is_some());

View File

@@ -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<u8> = 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"));
}

View File

@@ -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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -397,12 +406,3 @@ mod tests {
assert_eq!(h.next_event(false), None); 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
}

View File

@@ -25,7 +25,7 @@ pub enum PropertyValue {
} }
impl PropertyValue { impl PropertyValue {
fn from_default(default: PropertyDefault, ty: PropertyType) -> Self { pub fn from_default(default: PropertyDefault, ty: PropertyType) -> Self {
match default { match default {
PropertyDefault::Integer(v) if ty == PropertyType::IntegerArray => { PropertyDefault::Integer(v) if ty == PropertyType::IntegerArray => {
Self::IntegerArray(vec![v; 18]) Self::IntegerArray(vec![v; 18])
@@ -1659,7 +1659,7 @@ impl FormsModel {
false 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()?; self.instance(key).ok()?;
let mut left = self.integer(key, "LEFT").unwrap_or(0).max(0) as usize + 1; 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; let mut top = self.integer(key, "TOP").unwrap_or(0).max(0) as usize + 1;

View File

@@ -60,6 +60,26 @@ pub fn compile_project(
} }
impl ProjectCompiler { impl ProjectCompiler {
/// Bindungen aus den tatsächlich übersetzten Modulen einschließlich ihrer Imports.
pub fn bound_form_references(
&self,
) -> Result<tb_frontend::sema::BoundFormReferences, Vec<Diagnostic>> {
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( pub fn compile(
&mut self, &mut self,
name: &str, name: &str,

View File

@@ -43,7 +43,7 @@ Mausmodus und Alternate Screen zurück.
- Die Umgebung zeigt unten anklickbare Tastenkürzel und `00001:001` als - Die Umgebung zeigt unten anklickbare Tastenkürzel und `00001:001` als
Zeile/Spalte. Der Designer besitzt stattdessen die Properties Bar. Zeile/Spalte. Der Designer besitzt stattdessen die Properties Bar.
F10 wechselt dort zwischen Menü- und Properties Bar, F2 fokussiert Value. 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 ## 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 explizit in [PLAN.md, Phase 6](../PLAN.md). Ein umbenanntes TBC erfüllt
keines der beiden Exportziele. keines der beiden Exportziele.
## Befehlsabdeckung nach Change 03 ## Befehlsabdeckung nach Change 05
Alle Referenzbefehle sind in ihrer Menüfolge vorhanden. Fachbefehle der Alle Referenzbefehle sind in ihrer Menüfolge vorhanden. Fachbefehle der
folgenden Changes sind sichtbar deaktiviert und zeigen einen 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 | | 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 | — | | 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 | | 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, Output Screen | Grid Lines: 05; Next Statement: 06 | | 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 | — | | Search | Find, Selected Text, Repeat Last Find, Change | — |
| Run | Start/Restart/Continue/Pause, COMMAND$, wirksame Startdatei und beide Make-Dialoge | Native Erzeugung: Phase 6 | | 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 | | 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 | | 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 | | 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 | | 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. Phase-5-Abnahme erfolgt in Change 08.
## Programme ausführen ## 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 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 Include-Puffer bleiben maßgeblich gegenüber externen Dateiänderungen. Noch nicht
geöffnete Includes werden erneut vom Loader gelesen und verglichen. Start und 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). 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 `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 in einem unsichtbaren PTY die echten Shell-/Terminalübergaben und das Fortsetzen
nach STOP. Das Skript verwendet ausschließlich temporäre Projekte. 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 F1F10 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.

View File

@@ -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 | | 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 | | 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 | | 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 | | 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 | | 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) | 0107 | Durchgängiger Arbeitsablauf, Befehlsabdeckung und nachgewiesener PLAN-Status | | 08 | [Integration und Phasenabnahme](../../phase-5-08-integration-und-phasenabnahme/proposal.md) | 0107 | Durchgängiger Arbeitsablauf, Befehlsabdeckung und nachgewiesener PLAN-Status |

View File

@@ -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. 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.

View File

@@ -1,20 +1,20 @@
## 1. Designmodell und Vorschau ## 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. - [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.
- [ ] 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.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.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. 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. - [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.
- [ ] 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.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. - [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.
- [ ] 2.4 Color Palette und Designer-Window-Befehle anbinden; Eventtests prüfen Fore-/BackColor-Auftrag, Werkzeugfenster und Formauswahl. - [x] 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.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. 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. - [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.
- [ ] 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.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. - [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.
- [ ] 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.4 Designerbedienung dokumentieren und alle Spec-Szenarien in verification.md zuordnen; IDE-/FRM-/Forms-Metadaten-Tests und Formatprüfung müssen bestehen.

View File

@@ -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.

View File

@@ -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. 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. **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.

View File

@@ -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