Spezifikationsabgleich und Regressionsnachweise abschließen

This commit is contained in:
2026-09-06 10:31:16 +02:00
parent c8b92f0619
commit 024336e29c
39 changed files with 2485 additions and 120 deletions

View File

@@ -75,6 +75,7 @@ pub struct FormsModel {
visible_forms: Vec<u16>,
active_form: Option<u16>,
active_control: Option<(u16, Option<i32>)>,
dropdown: Option<ObjectKey>,
width: usize,
height: usize,
screen_visible: bool,
@@ -103,16 +104,35 @@ impl FormsModel {
};
if self.root_form(control) == Some(form) {
self.active_control = None;
self.dropdown = None;
}
}
fn activate_form(&mut self, form: Option<u16>) {
if self.active_form == form {
return;
}
if let Some(old) = self.active_form {
self.queue_named((old, None), "LOSTFOCUS", vec![]);
}
self.active_form = form;
self.dropdown = None;
if let Some(new) = form {
self.queue_named((new, None), "GOTFOCUS", vec![]);
}
}
pub fn new(objects: Vec<FormObject>, width: usize, height: usize) -> Self {
let objects = objects
.into_iter()
.map(|description| {
let properties = forms::properties(description.class)
let mut properties: Vec<_> = forms::properties(description.class)
.into_iter()
.map(|p| PropertyValue::from_default(p.default, p.ty))
.collect();
if let Some((id, _)) = forms::property(description.class, "PARENT") {
properties[id as usize] =
PropertyValue::Object(description.parent.map(|id| (id, None)));
}
let loaded = description.class == ObjectClass::Screen;
ObjectInstance {
description,
@@ -132,6 +152,7 @@ impl FormsModel {
visible_forms: Vec::new(),
active_form: None,
active_control: None,
dropdown: None,
width,
height,
screen_visible: true,
@@ -251,10 +272,6 @@ impl FormsModel {
.unwrap_or(1);
return Ok(PropertyValue::Integer(value.saturating_sub(2)));
}
if spec.name == "PARENT" {
let parent = obj.description.parent.map(|id| (id, None));
return Ok(PropertyValue::Object(parent));
}
Ok(obj.properties[property as usize].clone())
}
@@ -420,6 +437,7 @@ impl FormsModel {
self.replace_selection(key, &replacement)?;
return Ok(());
}
let changed = self.value(key, spec.name) != Some(&value);
let timer_reset = class == ObjectClass::Timer
&& (spec.name == "INTERVAL"
|| (spec.name == "ENABLED" && self.value(key, "ENABLED") != Some(&value)));
@@ -433,6 +451,12 @@ impl FormsModel {
);
}
}
if changed && class == ObjectClass::Form && matches!(spec.name, "WIDTH" | "HEIGHT") {
self.queue_named(key, "RESIZE", vec![]);
}
if changed && class == ObjectClass::Label && spec.name == "CAPTION" {
self.queue_named(key, "CHANGE", vec![]);
}
if spec.name == "SORTED" && self.boolean(key, "SORTED") {
self.sort_list(key)?;
}
@@ -464,6 +488,17 @@ impl FormsModel {
) && matches!(spec.name, "PATH" | "DRIVE" | "PATTERN")
{
self.refresh_filesystem(key)?;
if changed {
let event = match (class, spec.name) {
(ObjectClass::FileListBox, "PATTERN") => "PATTERNCHANGE",
(ObjectClass::FileListBox | ObjectClass::DirListBox, "PATH") => "PATHCHANGE",
_ => "CHANGE",
};
self.queue_named(key, event, vec![]);
if class == ObjectClass::DirListBox {
self.queue_named(key, "CHANGE", vec![]);
}
}
}
self.dirty = true;
Ok(())
@@ -566,7 +601,8 @@ impl FormsModel {
Self::set_visible_property(obj, true);
self.visible_forms.retain(|id| *id != object);
self.visible_forms.push(object);
self.active_form = Some(object);
self.activate_form(Some(object));
self.queue_named((object, None), "PAINT", vec![]);
self.dirty = true;
Ok(if modal {
ShowResult::ModalWait
@@ -590,7 +626,7 @@ impl FormsModel {
self.modal.pop();
}
if self.active_form == Some(object) {
self.active_form = self.visible_forms.last().copied();
self.activate_form(self.visible_forms.last().copied());
}
self.clear_active_control_for_form(object);
self.reset_form_timers(object);
@@ -629,7 +665,7 @@ impl FormsModel {
self.modal.pop();
}
if self.active_form == Some(object) {
self.active_form = self.visible_forms.last().copied();
self.activate_form(self.visible_forms.last().copied());
}
self.clear_active_control_for_form(object);
self.reset_form_timers(object);
@@ -693,6 +729,9 @@ impl FormsModel {
.map(|_| ())
.ok_or(RuntimeError(340));
if removed.is_ok() {
if self.dropdown == Some((base, Some(index))) {
self.dropdown = None;
}
self.reset_timer((base, Some(index)));
self.lists.remove(&(base, Some(index)));
}
@@ -745,10 +784,21 @@ impl FormsModel {
.ok_or(RuntimeError(420))?
.description
};
if matches!(description.class, ObjectClass::Form | ObjectClass::Screen) {
if matches!(
description.class,
ObjectClass::Form
| ObjectClass::Screen
| ObjectClass::Frame
| ObjectClass::Label
| ObjectClass::Menu
| ObjectClass::Timer
) {
return Err(RuntimeError(421));
}
self.active_form = self.root_form(key);
self.activate_form(self.root_form(key));
if self.active_control != Some(key) {
self.dropdown = None;
}
self.active_control = Some(key);
Ok(())
}
@@ -822,14 +872,28 @@ impl FormsModel {
});
}
fn root_form(&self, key: ObjectKey) -> Option<u16> {
let mut current = key.0;
for _ in 0..self.objects.len() {
let obj = self.objects.get(current as usize)?;
if obj.description.class == ObjectClass::Form {
return Some(current);
fn parent_key(&self, key: ObjectKey) -> Option<ObjectKey> {
match self.value(key, "PARENT") {
Some(PropertyValue::Object(parent)) => {
parent.map(|(id, index)| (id, index.filter(|i| *i != 0)))
}
current = obj.description.parent?;
_ => self
.instance(key)
.ok()?
.description
.parent
.map(|id| (id, None)),
}
}
fn root_form(&self, key: ObjectKey) -> Option<u16> {
let mut current = key;
for _ in 0..self.objects.len() + self.dynamic.len() {
let obj = self.instance(current).ok()?;
if obj.description.class == ObjectClass::Form {
return Some(current.0);
}
current = self.parent_key(current)?;
}
None
}
@@ -872,7 +936,10 @@ impl FormsModel {
if let Some(old) = self.active_control {
self.queue_named(old, "LOSTFOCUS", vec![]);
}
self.active_form = self.root_form(key);
self.activate_form(self.root_form(key));
if self.active_control != Some(key) {
self.dropdown = None;
}
self.active_control = Some(key);
self.queue_named(key, "GOTFOCUS", vec![]);
self.dirty = true;
@@ -949,7 +1016,7 @@ impl FormsModel {
}
fn select_option(&mut self, key: ObjectKey) -> Result<(), RuntimeError> {
let parent = self.instance(key)?.description.parent;
let parent = self.parent_key(key);
let value_id = forms::property(ObjectClass::OptionButton, "VALUE")
.unwrap()
.0 as usize;
@@ -959,7 +1026,7 @@ impl FormsModel {
}
let same_group = self.instance(other).is_ok_and(|obj| {
obj.description.class == ObjectClass::OptionButton
&& obj.description.parent == parent
&& self.parent_key(other) == parent
});
if same_group {
self.instance_mut(other)?.properties[value_id] = PropertyValue::Integer(0);
@@ -994,10 +1061,8 @@ impl FormsModel {
let title_shortcut = name == "SHORTCUT"
&& matches!(value, PropertyValue::String(text) if !text.is_empty())
&& self
.instance(key)
.ok()
.and_then(|obj| obj.description.parent)
.and_then(|parent| self.objects.get(parent as usize))
.parent_key(key)
.and_then(|parent| self.instance(parent).ok())
.is_some_and(|parent| parent.description.class == ObjectClass::Form);
if invalid || title_shortcut {
Err(RuntimeError::ILLEGAL_FUNCTION_CALL)
@@ -1163,12 +1228,6 @@ impl FormsModel {
}
entries.sort_by_key(|entry| entry.to_uppercase());
self.lists.insert(key, entries);
let event = match class {
ObjectClass::DirListBox => "PATHCHANGE",
ObjectClass::FileListBox if !self.string(key, "PATTERN").is_empty() => "PATTERNCHANGE",
_ => "CHANGE",
};
self.queue_named(key, event, vec![]);
self.dirty = true;
Ok(())
}
@@ -1204,7 +1263,12 @@ impl FormsModel {
let class = self.instance(key)?.description.class;
match name {
"SETFOCUS" => self.focus(key)?,
"REFRESH" => self.dirty = true,
"REFRESH" => {
if matches!(class, ObjectClass::Form | ObjectClass::PictureBox) {
self.queue_named(key, "PAINT", vec![]);
}
self.dirty = true;
}
"ADDITEM" => self.add_item(
key,
args.first().map(Self::text_of).unwrap_or_default(),
@@ -1304,10 +1368,27 @@ impl FormsModel {
}
fn form_controls(&self, form: u16) -> Vec<ObjectKey> {
self.keys()
let mut controls = Vec::new();
let mut seen = std::collections::HashSet::new();
for key in self
.keys()
.into_iter()
.filter(|key| self.root_form(*key) == Some(form) && key.0 != form)
.collect()
{
// Auch ein nachträglich angelegtes Array-Containerobjekt muss unter
// seinen Kindern liegen. Übrige Erzeugungsreihenfolge beibehalten.
let mut ancestors = Vec::new();
let mut current = Some(key);
while let Some(parent) = current.filter(|p| p.0 != form && !seen.contains(p)) {
ancestors.push(parent);
current = self.parent_key(parent);
}
for key in ancestors.into_iter().rev() {
seen.insert(key);
controls.push(key);
}
}
controls
}
fn menu_children(&self, parent: ObjectKey) -> Vec<ObjectKey> {
@@ -1316,7 +1397,7 @@ impl FormsModel {
.filter(|key| {
self.instance(*key).is_ok_and(|object| {
object.description.class == ObjectClass::Menu
&& object.description.parent == Some(parent.0)
&& self.parent_key(*key) == Some(parent)
}) && self.boolean(*key, "VISIBLE")
})
.collect()
@@ -1370,7 +1451,7 @@ impl FormsModel {
&& self.boolean(*candidate, "VISIBLE")
&& self.instance(*candidate).is_ok_and(|obj| {
obj.description.class != ObjectClass::Menu
|| obj.description.parent == Some(form)
|| self.parent_key(*candidate) == Some((form, None))
})
}) {
if self
@@ -1410,7 +1491,29 @@ impl FormsModel {
PropertyValue::Integer(shift as i32),
],
);
let handled = self.control_key(active, key);
let dropdown_key = self
.instance(active)
.is_ok_and(|o| o.description.class == ObjectClass::ComboBox)
&& shift & umschalt::ALT != 0
&& key == taste::sonder(80);
let handled = if dropdown_key {
self.dropdown = if self.dropdown == Some(active) {
None
} else {
Some(active)
};
if self.dropdown.is_some() {
self.queue_named(active, "DROPDOWN", vec![]);
}
self.dirty = true;
true
} else if self.dropdown == Some(active) && matches!(key, taste::ESC | taste::ENTER) {
self.dropdown = None;
self.dirty = true;
true
} else {
self.control_key(active, key)
};
if key.chars().count() == 1 {
self.queue_named(active, "KEYPRESS", vec![PropertyValue::Integer(code)]);
}
@@ -1453,6 +1556,17 @@ impl FormsModel {
PropertyValue::Integer(shift as i32),
],
);
if key.chars().count() == 1 {
self.queue_named((form, None), "KEYPRESS", vec![PropertyValue::Integer(code)]);
}
self.queue_named(
(form, None),
"KEYUP",
vec![
PropertyValue::Integer(code),
PropertyValue::Integer(shift as i32),
],
);
true
}
@@ -1546,19 +1660,18 @@ impl FormsModel {
}
fn rect(&self, key: ObjectKey) -> Option<(usize, usize, usize, usize)> {
let obj = 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 top = self.integer(key, "TOP").unwrap_or(0).max(0) as usize + 1;
let width = self.integer(key, "WIDTH").unwrap_or(1).max(1) as usize;
let height = self.integer(key, "HEIGHT").unwrap_or(1).max(1) as usize;
let mut parent = obj.description.parent;
for _ in 0..self.objects.len() {
let Some(id) = parent else { break };
let parent_obj = self.objects.get(id as usize)?;
let parent_key = (id, None);
let mut parent = self.parent_key(key);
for _ in 0..self.objects.len() + self.dynamic.len() {
let Some(parent_key) = parent else { break };
self.instance(parent_key).ok()?;
left += self.integer(parent_key, "LEFT").unwrap_or(0).max(0) as usize;
top += self.integer(parent_key, "TOP").unwrap_or(0).max(0) as usize;
parent = parent_obj.description.parent;
parent = self.parent_key(parent_key);
}
Some((left, top, width, height))
}
@@ -2192,7 +2305,7 @@ impl FormsModel {
format!("{text}")
};
let mut lines = vec![Self::fit(&first, width)];
if class == ObjectClass::ComboBox && style == 1 {
if class == ObjectClass::ComboBox && (style == 1 || self.dropdown == Some(key)) {
lines.extend(self.list_lines(key, width, height.saturating_sub(1)));
}
lines.truncate(height);
@@ -2317,12 +2430,7 @@ impl FormsModel {
let Ok(class) = self.instance(key).map(|obj| obj.description.class) else {
continue;
};
if class == ObjectClass::Menu
&& self
.instance(key)
.ok()
.is_some_and(|obj| obj.description.parent == Some(form))
{
if class == ObjectClass::Menu && self.parent_key(key) == Some((form, None)) {
if self.boolean(key, "VISIBLE") {
let caption = format!(" {} ", self.caption(key));
let focused = self.menu_path.first() == Some(&key);
@@ -2381,7 +2489,7 @@ impl FormsModel {
for root in self.form_controls(form).into_iter().filter(|key| {
self.instance(*key).is_ok_and(|object| {
object.description.class == ObjectClass::Menu
&& object.description.parent == Some(form)
&& self.parent_key(*key) == Some((form, None))
}) && self.boolean(*key, "VISIBLE")
}) {
if Some(&root) == self.menu_path.first() {
@@ -2749,6 +2857,90 @@ mod tests {
FormsModel::new(c.objects, 80, 25)
}
#[test]
fn combobox_dropdown_closes_on_focus_change_and_hide() {
let mut catalog = forms::FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
catalog.add("Combo", ObjectClass::ComboBox, Some("Form1"), false);
catalog.add("Text", ObjectClass::TextBox, Some("Form1"), false);
let mut m = FormsModel::new(catalog.objects, 80, 25);
m.show(0, false).unwrap();
for hide in [false, true] {
m.focus((1, None)).unwrap();
assert!(m.handle_key(&taste::sonder(80), umschalt::ALT));
assert_eq!(m.dropdown, Some((1, None)));
if hide {
m.hide(0).unwrap();
} else {
m.focus((2, None)).unwrap();
}
assert_eq!(m.dropdown, None);
}
}
#[test]
fn indexed_containers_preserve_parent_geometry_and_option_groups() {
let form = crate::frm::read_text(
"parent.frm",
r#"VERSION 1.00
Begin Form Form1
Width = 80
Height = 25
Begin Frame Group
Index = 0
Left = 2
Begin OptionButton Choice
Index = 0
End
End
Begin Frame Group
Index = 1
Width = 25
Height = 12
Left = 30
Top = 4
Begin OptionButton Choice
Index = 1
Left = 2
Top = 2
End
Begin OptionButton Other
Caption = "Second"
Width = 12
Left = 2
Top = 4
End
End
End
"#,
)
.unwrap();
let catalog = form.catalog();
let id = |name| catalog.find(name).unwrap().0;
let (root, group, choice, other) = (id("Form1"), id("Group"), id("Choice"), id("Other"));
let mut m = FormsModel::new(catalog.objects, 80, 25);
form.apply(&mut m).unwrap();
m.show(root, false).unwrap();
assert_eq!(m.parent_key((choice, Some(1))), Some((group, Some(1))));
assert_eq!(m.root_form((choice, Some(1))), Some(root));
assert_eq!(
m.rect((choice, Some(1))).map(|(x, y, _, _)| (x, y)),
Some((33, 7))
);
assert_eq!(m.hit_test(root, 7, 33), (choice, Some(1)));
m.activate((choice, None)).unwrap();
m.activate((choice, Some(1))).unwrap();
assert_eq!(m.integer((choice, None), "VALUE"), Some(-1));
assert_eq!(m.integer((choice, Some(1)), "VALUE"), Some(-1));
m.activate((other, None)).unwrap();
assert_eq!(m.integer((choice, None), "VALUE"), Some(-1));
assert_eq!(m.integer((choice, Some(1)), "VALUE"), Some(0));
assert_eq!(m.hit_test(root, 9, 33), (other, None));
let mut screen = TextScreen::new();
m.render(&mut screen);
assert!(tb_runtime::snapshot::text(&screen).contains("Second"));
}
#[test]
fn defaults_bereich_und_implizites_laden() {
let mut m = model();
@@ -3633,7 +3825,8 @@ mod tests {
m.unload_array(2, 1).unwrap();
assert!(!m.events.iter().any(|e| e.array_index == Some(1)));
m.hide(0).unwrap();
assert!(m.events.is_empty());
assert_eq!(m.events.len(), 1);
assert_eq!(m.events.pop_front().unwrap().name, "LOSTFOCUS");
m.show(0, false).unwrap();
m.sync_timers(|| 2000);
assert_eq!(m.next_deadline(), Some(2100));