Spezifikationsabgleich und Regressionsnachweise abschließen
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user