Phase 4: Formularmodell und Objektsprache

This commit is contained in:
2026-09-04 17:30:32 +02:00
parent 5cf5a6582c
commit 54488b5b67
35 changed files with 7195 additions and 627 deletions

View File

@@ -8,6 +8,7 @@ authors.workspace = true
[dependencies]
tb-runtime.workspace = true
tb-frontend.workspace = true
ratatui.workspace = true
crossterm.workspace = true
signal-hook.workspace = true

View File

@@ -3,4 +3,592 @@
//! Ziel ist volle Kompatibilität zum Forms-Modell des Vorbilds inklusive
//! des textbasierten Formular-Dateiformats (`.FRM`).
// Platzhalter — wird in Phase 4 ausgearbeitet (siehe PLAN.md)
use std::collections::{BTreeMap, VecDeque};
use tb_frontend::forms::{self, FormObject, ObjectClass, PropertyDefault, PropertyType};
use tb_runtime::errors::RuntimeError;
#[derive(Debug, Clone, PartialEq)]
pub enum PropertyValue {
Integer(i32),
Single(f32),
String(String),
Boolean(bool),
Object(Option<(u16, Option<i32>)>),
IntegerArray(Vec<i32>),
}
impl PropertyValue {
fn from_default(default: PropertyDefault, ty: PropertyType) -> Self {
match default {
PropertyDefault::Integer(v) if ty == PropertyType::IntegerArray => {
Self::IntegerArray(vec![v; 18])
}
PropertyDefault::Integer(v) => Self::Integer(v),
PropertyDefault::Single(v) => Self::Single(v),
PropertyDefault::String(v) => Self::String(v.into()),
PropertyDefault::Boolean(v) => Self::Boolean(v),
PropertyDefault::Empty if ty == PropertyType::Object => Self::Object(None),
PropertyDefault::Empty if ty == PropertyType::String => Self::String(String::new()),
PropertyDefault::Empty => Self::Integer(0),
}
}
}
#[derive(Debug, Clone)]
pub struct ObjectInstance {
pub description: FormObject,
properties: Vec<PropertyValue>,
pub loaded: bool,
pub visible: bool,
pub design_time: bool,
pub array_index: Option<i32>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FormEvent {
pub object: u16,
pub array_index: Option<i32>,
pub name: String,
pub args: Vec<PropertyValue>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShowResult {
Modeless,
ModalWait,
}
/// Reines Zustandsmodell. Zeichnen und Bedienung kommen im Folge-Change;
/// diese Schicht hält Objekte, Lebenszyklus, Modalität und Ereignisqueue.
pub struct FormsModel {
pub objects: Vec<ObjectInstance>,
dynamic: BTreeMap<(u16, i32), ObjectInstance>,
pub events: VecDeque<FormEvent>,
modal: Vec<u16>,
visible_forms: Vec<u16>,
active_form: Option<u16>,
active_control: Option<(u16, Option<i32>)>,
width: usize,
height: usize,
screen_visible: bool,
}
impl FormsModel {
fn set_visible_property(obj: &mut ObjectInstance, visible: bool) {
if let Some((property, _)) = forms::property(obj.description.class, "VISIBLE") {
obj.properties[property as usize] = PropertyValue::Boolean(visible);
}
}
fn clear_active_control_for_form(&mut self, form: u16) {
let Some((control, _)) = self.active_control else {
return;
};
let Some(form_name) = self
.objects
.get(form as usize)
.map(|o| o.description.name.as_str())
else {
return;
};
if self
.objects
.get(control as usize)
.and_then(|o| o.description.parent_form.as_deref())
.is_some_and(|parent| parent.eq_ignore_ascii_case(form_name))
{
self.active_control = None;
}
}
pub fn new(objects: Vec<FormObject>, width: usize, height: usize) -> Self {
let objects = objects
.into_iter()
.map(|description| {
let properties = forms::properties(description.class)
.into_iter()
.map(|p| PropertyValue::from_default(p.default, p.ty))
.collect();
let loaded = description.class == ObjectClass::Screen;
ObjectInstance {
description,
properties,
loaded,
visible: false,
design_time: true,
array_index: None,
}
})
.collect();
Self {
objects,
dynamic: BTreeMap::new(),
events: VecDeque::new(),
modal: Vec::new(),
visible_forms: Vec::new(),
active_form: None,
active_control: None,
width,
height,
screen_visible: true,
}
}
pub fn resize(&mut self, width: usize, height: usize) {
self.width = width;
self.height = height;
}
pub fn get(&mut self, object: u16, property: u16) -> Result<PropertyValue, RuntimeError> {
self.get_at(object, None, property)
}
pub fn get_at(
&mut self,
object: u16,
index: Option<i32>,
property: u16,
) -> Result<PropertyValue, RuntimeError> {
self.ensure_loaded_at(object, index)?;
let obj = if index.is_some_and(|i| i != 0) {
self.dynamic
.get(&(object, index.unwrap()))
.ok_or(RuntimeError(340))?
} else {
self.objects.get(object as usize).ok_or(RuntimeError(420))?
};
let spec = forms::properties(obj.description.class)
.get(property as usize)
.copied()
.ok_or(RuntimeError(422))?;
if obj.description.class == ObjectClass::Screen {
return Ok(match spec.name {
"WIDTH" => PropertyValue::Integer(self.width as i32),
"HEIGHT" => PropertyValue::Integer(self.height as i32),
"ACTIVEFORM" => PropertyValue::Object(self.active_form.map(|id| (id, None))),
"ACTIVECONTROL" => PropertyValue::Object(self.active_control),
_ => obj.properties[property as usize].clone(),
});
}
Ok(obj.properties[property as usize].clone())
}
pub fn set(
&mut self,
object: u16,
property: u16,
value: PropertyValue,
) -> Result<(), RuntimeError> {
self.set_at(object, None, property, value)
}
pub fn set_at(
&mut self,
object: u16,
index: Option<i32>,
property: u16,
value: PropertyValue,
) -> Result<(), RuntimeError> {
self.ensure_loaded_at(object, index)?;
let obj = if index.is_some_and(|i| i != 0) {
self.dynamic
.get_mut(&(object, index.unwrap()))
.ok_or(RuntimeError(340))?
} else {
self.objects
.get_mut(object as usize)
.ok_or(RuntimeError(420))?
};
let spec = forms::properties(obj.description.class)
.get(property as usize)
.copied()
.ok_or(RuntimeError(422))?;
if !spec.writable {
return Err(RuntimeError(383));
}
let type_ok = matches!(
(&value, spec.ty),
(PropertyValue::Integer(_), PropertyType::Integer)
| (PropertyValue::Single(_), PropertyType::Single)
| (PropertyValue::String(_), PropertyType::String)
| (PropertyValue::Boolean(_), PropertyType::Boolean)
| (PropertyValue::Object(_), PropertyType::Object)
| (PropertyValue::IntegerArray(_), PropertyType::IntegerArray)
);
if !type_ok {
return Err(RuntimeError::TYPE_MISMATCH);
}
if let PropertyValue::Integer(v) = value {
if spec.min.is_some_and(|min| v < min) || spec.max.is_some_and(|max| v > max) {
return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
}
obj.properties[property as usize] = PropertyValue::Integer(v);
} else {
obj.properties[property as usize] = value;
}
if spec.name == "VISIBLE" {
obj.visible = matches!(
obj.properties[property as usize],
PropertyValue::Boolean(true)
);
}
Ok(())
}
pub fn ensure_loaded(&mut self, object: u16) -> Result<(), RuntimeError> {
let obj = self
.objects
.get_mut(object as usize)
.ok_or(RuntimeError(420))?;
if obj.loaded {
return Ok(());
}
obj.loaded = true;
if obj.description.class == ObjectClass::Form {
self.events.push_back(FormEvent {
object,
array_index: None,
name: "LOAD".into(),
args: Vec::new(),
});
}
Ok(())
}
pub fn ensure_loaded_at(
&mut self,
object: u16,
index: Option<i32>,
) -> Result<(), RuntimeError> {
if index.is_some_and(|i| i != 0) {
self.dynamic
.get(&(object, index.unwrap()))
.map(|_| ())
.ok_or(RuntimeError(340))
} else {
self.ensure_loaded(object)
}
}
pub fn show(&mut self, object: u16, modal: bool) -> Result<ShowResult, RuntimeError> {
self.ensure_loaded(object)?;
let obj = self
.objects
.get_mut(object as usize)
.ok_or(RuntimeError(420))?;
if obj.description.class != ObjectClass::Form {
return Err(RuntimeError(421));
}
if modal {
if obj.visible {
return Err(RuntimeError(400));
}
let form_type = forms::property(ObjectClass::Form, "FORMTYPE").unwrap().0 as usize;
if obj.properties.get(form_type) == Some(&PropertyValue::Integer(1)) {
return Err(RuntimeError(403));
}
self.modal.push(object);
} else if !self.modal.is_empty() {
return Err(RuntimeError(401));
}
obj.visible = true;
Self::set_visible_property(obj, true);
self.visible_forms.retain(|id| *id != object);
self.visible_forms.push(object);
self.active_form = Some(object);
Ok(if modal {
ShowResult::ModalWait
} else {
ShowResult::Modeless
})
}
pub fn hide(&mut self, object: u16) -> Result<(), RuntimeError> {
if self.modal.contains(&object) && self.modal.last().copied() != Some(object) {
return Err(RuntimeError(402));
}
let obj = self
.objects
.get_mut(object as usize)
.ok_or(RuntimeError(420))?;
obj.visible = false;
Self::set_visible_property(obj, false);
self.visible_forms.retain(|id| *id != object);
if self.modal.last().copied() == Some(object) {
self.modal.pop();
}
if self.active_form == Some(object) {
self.active_form = self.visible_forms.last().copied();
}
self.clear_active_control_for_form(object);
Ok(())
}
pub fn unload_with(
&mut self,
object: u16,
mut handler: impl FnMut(&mut i16),
) -> Result<bool, RuntimeError> {
if self.modal.contains(&object) && self.modal.last().copied() != Some(object) {
return Err(RuntimeError(402));
}
let class = self
.objects
.get(object as usize)
.ok_or(RuntimeError(420))?
.description
.class;
if class != ObjectClass::Form {
return Err(RuntimeError(361));
}
let mut cancel = 0i16;
handler(&mut cancel);
if cancel != 0 {
return Ok(false);
}
let obj = &mut self.objects[object as usize];
obj.visible = false;
Self::set_visible_property(obj, false);
obj.loaded = false;
self.visible_forms.retain(|id| *id != object);
if self.modal.last().copied() == Some(object) {
self.modal.pop();
}
if self.active_form == Some(object) {
self.active_form = self.visible_forms.last().copied();
}
self.clear_active_control_for_form(object);
Ok(true)
}
pub fn load_array(&mut self, base: u16, index: i32) -> Result<(), RuntimeError> {
let template = self.objects.get(base as usize).ok_or(RuntimeError(420))?;
if !template.description.array {
return Err(RuntimeError(343));
}
if index == 0 {
return Err(RuntimeError(360));
}
let key = (base, index);
if self.dynamic.contains_key(&key) {
return Err(RuntimeError(360));
}
let mut obj = template.clone();
obj.loaded = true;
obj.visible = false;
obj.design_time = false;
obj.array_index = Some(index);
self.dynamic.insert(key, obj);
Ok(())
}
pub fn unload_array(&mut self, base: u16, index: i32) -> Result<(), RuntimeError> {
let template = self.objects.get(base as usize).ok_or(RuntimeError(420))?;
if !template.description.array {
return Err(RuntimeError(343));
}
if template.array_index == Some(index) || (template.array_index.is_none() && index == 0) {
return Err(RuntimeError(362));
}
self.dynamic
.remove(&(base, index))
.map(|_| ())
.ok_or(RuntimeError(340))
}
pub fn queue(&mut self, event: FormEvent) {
self.events.push_back(event);
}
pub fn next_event(&mut self) -> Option<FormEvent> {
self.events.pop_front()
}
pub fn is_visible(&self, object: u16) -> bool {
self.objects.get(object as usize).is_some_and(|o| o.visible)
}
pub fn is_loaded(&self, object: u16) -> bool {
self.objects.get(object as usize).is_some_and(|o| o.loaded)
}
pub fn is_loaded_at(&self, object: u16, index: Option<i32>) -> bool {
if index.is_some_and(|i| i != 0) {
self.dynamic.contains_key(&(object, index.unwrap()))
} else {
self.is_loaded(object)
}
}
pub fn active_form(&self) -> Option<u16> {
self.active_form
}
pub fn set_active_control(
&mut self,
object: u16,
index: Option<i32>,
) -> Result<(), RuntimeError> {
self.ensure_loaded_at(object, index)?;
let description = if index.is_some_and(|i| i != 0) {
&self
.dynamic
.get(&(object, index.unwrap()))
.unwrap()
.description
} else {
&self
.objects
.get(object as usize)
.ok_or(RuntimeError(420))?
.description
};
if matches!(description.class, ObjectClass::Form | ObjectClass::Screen) {
return Err(RuntimeError(421));
}
let parent = description
.parent_form
.as_deref()
.ok_or(RuntimeError(421))?;
self.active_form = self
.objects
.iter()
.position(|o| {
o.description.class == ObjectClass::Form
&& o.description.name.eq_ignore_ascii_case(parent)
})
.map(|id| id as u16);
self.active_control = Some((object, index));
Ok(())
}
pub fn modal_top(&self) -> Option<u16> {
self.modal.last().copied()
}
pub fn screen_show(&mut self, visible: bool) {
self.screen_visible = visible;
}
pub fn screen_visible(&self) -> bool {
self.screen_visible
}
}
#[cfg(test)]
mod tests {
use super::*;
fn model() -> FormsModel {
let mut c = forms::FormCatalog::default();
c.add("Form1", ObjectClass::Form, None, false);
c.add("Check1", ObjectClass::CheckBox, Some("Form1"), false);
c.add("Command1", ObjectClass::CommandButton, Some("Form1"), true);
c.add("SCREEN", ObjectClass::Screen, None, false);
FormsModel::new(c.objects, 80, 25)
}
#[test]
fn defaults_bereich_und_implizites_laden() {
let mut m = model();
let value = forms::property(ObjectClass::CheckBox, "VALUE").unwrap().0;
assert_eq!(m.get(1, value).unwrap(), PropertyValue::Integer(0));
assert!(m.objects[1].loaded);
assert_eq!(
m.set(1, value, PropertyValue::Integer(3)),
Err(RuntimeError(5))
);
}
#[test]
fn hide_entlaedt_nicht_und_unload_ist_abbrechbar() {
let mut m = model();
m.show(0, false).unwrap();
m.hide(0).unwrap();
assert!(m.objects[0].loaded);
assert!(!m.unload_with(0, |cancel| *cancel = 1).unwrap());
assert!(m.objects[0].loaded);
assert!(m.unload_with(0, |_| {}).unwrap());
}
#[test]
fn arrays_und_designzeitfehler() {
let mut m = model();
let caption = forms::property(ObjectClass::CommandButton, "CAPTION")
.unwrap()
.0;
m.set(2, caption, PropertyValue::String("Vorlage".into()))
.unwrap();
assert_eq!(m.load_array(2, 0), Err(RuntimeError(360)));
m.load_array(2, 3).unwrap();
assert_eq!(
m.get_at(2, Some(3), caption).unwrap(),
PropertyValue::String("Vorlage".into())
);
m.set_at(2, Some(3), caption, PropertyValue::String("Klon".into()))
.unwrap();
assert_eq!(
m.get(2, caption).unwrap(),
PropertyValue::String("Vorlage".into())
);
assert_eq!(m.unload_array(2, 0), Err(RuntimeError(362)));
m.unload_array(2, 3).unwrap();
}
#[test]
fn screen_meldet_oberstes_formular_und_aktives_control() {
let mut c = forms::FormCatalog::default();
c.add("A", ObjectClass::Form, None, false);
c.add("B", ObjectClass::Form, None, false);
c.add("Text1", ObjectClass::TextBox, Some("B"), false);
c.add("SCREEN", ObjectClass::Screen, None, false);
let mut m = FormsModel::new(c.objects, 80, 25);
m.show(0, false).unwrap();
m.show(1, false).unwrap();
m.set_active_control(2, None).unwrap();
let active_form = forms::property(ObjectClass::Screen, "ACTIVEFORM")
.unwrap()
.0;
let active_control = forms::property(ObjectClass::Screen, "ACTIVECONTROL")
.unwrap()
.0;
assert_eq!(
m.get(3, active_form).unwrap(),
PropertyValue::Object(Some((1, None)))
);
assert_eq!(
m.get(3, active_control).unwrap(),
PropertyValue::Object(Some((2, None)))
);
m.hide(1).unwrap();
assert_eq!(
m.get(3, active_form).unwrap(),
PropertyValue::Object(Some((0, None)))
);
assert_eq!(
m.get(3, active_control).unwrap(),
PropertyValue::Object(None)
);
}
#[test]
fn screen_folgt_resize() {
let mut m = model();
m.resize(120, 40);
let width = forms::property(ObjectClass::Screen, "WIDTH").unwrap().0;
let height = forms::property(ObjectClass::Screen, "HEIGHT").unwrap().0;
assert_eq!(m.get(3, width).unwrap(), PropertyValue::Integer(120));
assert_eq!(m.get(3, height).unwrap(), PropertyValue::Integer(40));
}
#[test]
fn modalfehler_400_bis_403() {
let mut m = model();
m.show(0, false).unwrap();
assert_eq!(m.show(0, true), Err(RuntimeError(400)));
m.hide(0).unwrap();
m.show(0, true).unwrap();
assert_eq!(m.show(0, false), Err(RuntimeError(401)));
let mut c = forms::FormCatalog::default();
c.add("A", ObjectClass::Form, None, false);
c.add("B", ObjectClass::Form, None, false);
let mut stacked = FormsModel::new(c.objects, 80, 25);
stacked.show(0, true).unwrap();
stacked.show(1, true).unwrap();
assert_eq!(stacked.hide(0), Err(RuntimeError(402)));
let ft = forms::property(ObjectClass::Form, "FORMTYPE").unwrap().0;
let mut mdi = model();
mdi.set(0, ft, PropertyValue::Integer(1)).unwrap();
assert_eq!(mdi.show(0, true), Err(RuntimeError(403)));
}
}