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

View File

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

View File

@@ -234,3 +234,38 @@ fn explicit_startup_order_and_form_selection_survive_tbc() {
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn designer_frm_is_accepted_by_the_standalone_cli_compiler() {
use tb_frontend::forms::ObjectClass;
use tb_ide::{app::App, commands::Command as IdeCommand, documents::Destination};
let dir = std::env::temp_dir().join(format!("tb-designer-cli-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let dir = dir.canonicalize().unwrap();
let mut app = App::new(&dir, dir.join("options"), (100, 30)).unwrap();
app.execute(IdeCommand::NewForm);
let root = app.design_objects().unwrap()[0].0;
let button = app.design_place(ObjectClass::CommandButton, 0, root, Default::default());
assert!(button.is_err()); // ungültige Größe schreibt nichts
let mut rect = app.area();
rect.x = 2;
rect.y = 2;
rect.width = 8;
rect.height = 2;
let control = app
.design_place(ObjectClass::CommandButton, 0, root, rect)
.unwrap();
app.design_property("INDEX", "0").unwrap();
app.design_event(control, "CLICK").unwrap();
let doc = app.active_document().unwrap();
let path = dir.join("designed.frm");
app.project
.save_file(doc, Some(&Destination::new(&path)))
.unwrap();
let built = tbc(&dir, "build", "designed.frm");
assert!(built.status.success(), "{built:?}");
let bytes = std::fs::read(dir.join("designed.tbc")).unwrap();
assert_eq!(&bytes[..6], b"TBC\0\x04\0");
tb_vm::project_io::load_program(&dir.join("designed.tbc")).unwrap();
std::fs::remove_dir_all(dir).unwrap();
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -140,7 +140,8 @@ fn start_snapshot_and_reference_menus_have_all_commands() {
let name = item.text();
assert!(
reference.contains(name.trim_end_matches('…'))
|| item.command == Some(Command::Diagnostics),
|| item.command == Some(Command::Diagnostics)
|| matches!(item.command, Some(Command::Tool(_))),
"Nicht in Referenz: {name}"
);
assert!(item.mnemonic().is_some());

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)]
mod tests {
use super::*;
@@ -397,12 +406,3 @@ mod tests {
assert_eq!(h.next_event(false), None);
}
}
/// Shared child command, inheriting the foreground terminal and stdio.
pub fn shell_command(command: &str) -> std::process::Command {
let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "sh" });
if !command.is_empty() {
child.args([if cfg!(windows) { "/C" } else { "-c" }, command]);
}
child
}

View File

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

View File

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