//! Forms-Engine: Formulare, Steuerelemente, Eigenschaften, Methoden. //! //! Ziel ist volle Kompatibilität zum Forms-Modell des Vorbilds inklusive //! des textbasierten Formular-Dateiformats (`.FRM`). use std::collections::{BTreeMap, VecDeque}; use std::path::PathBuf; use tb_frontend::forms::{self, FormObject, ObjectClass, PropertyDefault, PropertyType}; use tb_runtime::errors::RuntimeError; use tb_runtime::host::{taste, umschalt, Ereignis, Host, MausArt, MausEreignis}; use tb_runtime::screen::TextScreen; use unicode_width::UnicodeWidthChar; type ObjectKey = (u16, Option); #[derive(Debug, Clone, PartialEq)] pub enum PropertyValue { Integer(i32), Single(f32), String(String), Boolean(bool), Object(Option<(u16, Option)>), IntegerArray(Vec), } 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 if ty == PropertyType::Boolean => Self::Boolean(false), PropertyDefault::Empty => Self::Integer(0), } } } #[derive(Debug, Clone)] pub struct ObjectInstance { pub description: FormObject, properties: Vec, pub loaded: bool, pub visible: bool, pub design_time: bool, pub array_index: Option, } #[derive(Debug, Clone, PartialEq)] pub struct FormEvent { pub object: u16, pub array_index: Option, pub name: String, pub args: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ShowResult { Modeless, ModalWait, } /// Zustandsmodell, Bedienung und Darstellung der Forms-Objekte. pub struct FormsModel { pub objects: Vec, dynamic: BTreeMap<(u16, i32), ObjectInstance>, pub events: VecDeque, modal: Vec, visible_forms: Vec, active_form: Option, active_control: Option<(u16, Option)>, dropdown: Option, width: usize, height: usize, screen_visible: bool, lists: BTreeMap>, pictures: BTreeMap>, timer_last: BTreeMap, pressed: Option, spin_repeat: Option<(ObjectKey, i32)>, spin_last: Option, dragging: Option, drag_over: Option, menu_path: Vec, last_click: Option<(ObjectKey, u8, usize, usize, u64)>, dirty: 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; }; if self.root_form(control) == Some(form) { self.active_control = None; self.dropdown = None; } } fn activate_form(&mut self, form: Option) { 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, width: usize, height: usize) -> Self { let objects = objects .into_iter() .map(|description| { 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, 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, dropdown: None, width, height, screen_visible: true, lists: BTreeMap::new(), pictures: BTreeMap::new(), timer_last: BTreeMap::new(), pressed: None, spin_repeat: None, spin_last: None, dragging: None, drag_over: None, menu_path: Vec::new(), last_click: None, dirty: true, } } pub fn resize(&mut self, width: usize, height: usize) { if self.width == width && self.height == height { return; } self.width = width; self.height = height; self.dirty = true; } pub fn get(&mut self, object: u16, property: u16) -> Result { self.get_at(object, None, property) } pub fn get_at( &mut self, object: u16, index: Option, property: u16, ) -> Result { 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))?; let key = (object, index.filter(|i| *i != 0)); if spec.name == "LISTCOUNT" { return Ok(PropertyValue::Integer( self.lists.get(&key).map_or(0, |items| items.len()) as i32, )); } if spec.name == "TEXT" && matches!( obj.description.class, ObjectClass::ListBox | ObjectClass::ComboBox | ObjectClass::DirListBox | ObjectClass::DriveListBox | ObjectClass::FileListBox ) { return Ok(PropertyValue::String(self.list_text(key))); } if spec.name == "SELTEXT" && matches!( obj.description.class, ObjectClass::TextBox | ObjectClass::ComboBox ) { let start = forms::property(obj.description.class, "SELSTART") .and_then(|(id, _)| match obj.properties.get(id as usize) { Some(PropertyValue::Integer(v)) => Some(*v), _ => None, }) .unwrap_or(0) .max(0) as usize; let length = forms::property(obj.description.class, "SELLENGTH") .and_then(|(id, _)| match obj.properties.get(id as usize) { Some(PropertyValue::Integer(v)) => Some(*v), _ => None, }) .unwrap_or(0) .max(0) as usize; let text = forms::property(obj.description.class, "TEXT") .and_then(|(id, _)| match obj.properties.get(id as usize) { Some(PropertyValue::String(v)) => Some(v.as_str()), _ => None, }) .unwrap_or(""); return Ok(PropertyValue::String( text.chars().skip(start).take(length).collect(), )); } 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(), }); } if matches!(spec.name, "SCALEWIDTH" | "SCALEHEIGHT") { let dimension = if spec.name == "SCALEWIDTH" { "WIDTH" } else { "HEIGHT" }; let value = forms::property(obj.description.class, dimension) .and_then(|(id, _)| match obj.properties.get(id as usize) { Some(PropertyValue::Integer(v)) => Some(*v), _ => None, }) .unwrap_or(1); return Ok(PropertyValue::Integer(value.saturating_sub(2))); } Ok(obj.properties[property as usize].clone()) } pub fn get_indexed( &mut self, object: u16, property: u16, index: i32, ) -> Result { self.get_indexed_at(object, None, property, index) } pub fn get_indexed_at( &mut self, object: u16, object_index: Option, property: u16, index: i32, ) -> Result { self.ensure_loaded_at(object, object_index)?; let key = (object, object_index.filter(|value| *value != 0)); let obj = self.instance(key)?; let spec = forms::properties(obj.description.class) .get(property as usize) .copied() .ok_or(RuntimeError(422))?; if index < 0 { return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); } if spec.name == "LIST" { return self .lists .get(&key) .and_then(|items| items.get(index as usize)) .cloned() .map(PropertyValue::String) .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL); } match obj.properties.get(property as usize) { Some(PropertyValue::IntegerArray(values)) => values .get(index as usize) .copied() .map(PropertyValue::Integer) .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL), _ => Err(RuntimeError::ILLEGAL_FUNCTION_CALL), } } pub fn set_indexed_at( &mut self, object: u16, object_index: Option, property: u16, index: i32, value: i32, ) -> Result<(), RuntimeError> { self.ensure_loaded_at(object, object_index)?; let key = (object, object_index.filter(|value| *value != 0)); let obj = self.instance_mut(key)?; let spec = forms::properties(obj.description.class) .get(property as usize) .copied() .ok_or(RuntimeError(422))?; if !spec.writable || index < 0 || spec.min.is_some_and(|min| value < min) || spec.max.is_some_and(|max| value > max) { return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); } match obj.properties.get_mut(property as usize) { Some(PropertyValue::IntegerArray(values)) => { let slot = values .get_mut(index as usize) .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; *slot = value; self.dirty = true; Ok(()) } _ => Err(RuntimeError::TYPE_MISMATCH), } } 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, property: u16, value: PropertyValue, ) -> Result<(), RuntimeError> { self.ensure_loaded_at(object, index)?; let key = (object, index.filter(|i| *i != 0)); let class = self.instance(key)?.description.class; let spec = forms::properties(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); } } if spec.name == "LISTINDEX" { let PropertyValue::Integer(v) = value else { unreachable!() }; if v < -1 || (v >= 0 && v as usize >= self.lists.get(&key).map_or(0, Vec::len)) { return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); } self.instance_mut(key)?.properties[property as usize] = PropertyValue::Integer(v); self.dirty = true; return Ok(()); } if matches!( class, ObjectClass::HScrollBar | ObjectClass::VScrollBar | ObjectClass::Spin ) && spec.name == "VALUE" { let PropertyValue::Integer(v) = value else { unreachable!() }; let min = self.integer(key, "MIN").unwrap_or(0); let max = self.integer(key, "MAX").unwrap_or(32767); if v < min || v > max { return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); } self.instance_mut(key)?.properties[property as usize] = PropertyValue::Integer(v); self.queue_named(key, "CHANGE", vec![]); self.dirty = true; return Ok(()); } if class == ObjectClass::Menu { self.validate_menu_value(key, spec.name, &value)?; } if spec.name == "SELTEXT" && matches!(class, ObjectClass::TextBox | ObjectClass::ComboBox) { let PropertyValue::String(replacement) = value else { unreachable!() }; 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))); { let obj = self.instance_mut(key)?; obj.properties[property as usize] = value; if spec.name == "VISIBLE" { obj.visible = matches!( obj.properties[property as usize], PropertyValue::Boolean(true) ); } } 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)?; } if timer_reset { self.reset_timer(key); } if class == ObjectClass::Form && spec.name == "VISIBLE" && !self.is_visible(object) { self.reset_form_timers(object); } if spec.name == "TEXT" && matches!(class, ObjectClass::TextBox | ObjectClass::ComboBox) { self.queue_named(key, "CHANGE", vec![]); } if spec.name == "VALUE" && class == ObjectClass::CommandButton && self.boolean(key, "VALUE") { self.queue_named(key, "CLICK", vec![]); if let Some((id, _)) = forms::property(class, "VALUE") { self.instance_mut(key)?.properties[id as usize] = PropertyValue::Boolean(false); } } if spec.name == "VALUE" && class == ObjectClass::OptionButton && self.integer(key, "VALUE").is_some_and(|value| value != 0) { self.select_option(key)?; } if matches!( class, ObjectClass::DirListBox | ObjectClass::DriveListBox | ObjectClass::FileListBox ) && 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(()) } pub fn set_initial( &mut self, object: u16, property: u16, value: PropertyValue, ) -> Result<(), RuntimeError> { self.set_initial_at(object, None, property, value) } pub fn set_initial_at( &mut self, object: u16, index: Option, property: u16, value: PropertyValue, ) -> Result<(), RuntimeError> { let key = (object, index.filter(|value| *value != 0)); let obj = self.instance_mut(key)?; if property as usize >= obj.properties.len() { return Err(RuntimeError(422)); } if forms::properties(obj.description.class)[property as usize].name == "VISIBLE" && obj.description.class != ObjectClass::Form { obj.visible = matches!(value, PropertyValue::Boolean(true)); } obj.properties[property as usize] = value; if forms::properties(obj.description.class)[property as usize].name == "SORTED" && self.boolean(key, "SORTED") { self.sort_list(key)?; } self.dirty = 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(), }); } self.dirty = true; Ok(()) } pub fn ensure_loaded_at( &mut self, object: u16, index: Option, ) -> 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 { 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.activate_form(Some(object)); self.queue_named((object, None), "PAINT", vec![]); self.dirty = true; 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.activate_form(self.visible_forms.last().copied()); } self.clear_active_control_for_form(object); self.reset_form_timers(object); self.dirty = true; Ok(()) } pub fn unload_with( &mut self, object: u16, mut handler: impl FnMut(&mut i16), ) -> Result { 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.activate_form(self.visible_forms.last().copied()); } self.clear_active_control_for_form(object); self.reset_form_timers(object); self.dirty = true; 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; Self::set_visible_property(&mut obj, false); obj.design_time = false; obj.array_index = Some(index); self.dynamic.insert(key, obj); self.dirty = true; Ok(()) } pub(crate) fn load_design_array(&mut self, base: u16, index: i32) -> Result<(), RuntimeError> { self.load_array(base, index)?; let object = self .dynamic .get_mut(&(base, index)) .ok_or(RuntimeError(340))?; object.design_time = true; object.visible = true; Self::set_visible_property(object, true); 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)); } if self .dynamic .get(&(base, index)) .is_some_and(|object| object.design_time) { return Err(RuntimeError(362)); } let removed = self .dynamic .remove(&(base, index)) .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))); } self.dirty = true; removed } pub fn queue(&mut self, event: FormEvent) { self.events.push_back(event); } pub fn next_event(&mut self) -> Option { 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 has_visible_forms(&self) -> bool { !self.visible_forms.is_empty() } 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) -> 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 { self.active_form } pub fn set_active_control( &mut self, object: u16, index: Option, ) -> Result<(), RuntimeError> { self.ensure_loaded_at(object, index)?; let key = (object, index.filter(|i| *i != 0)); 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 | ObjectClass::Frame | ObjectClass::Label | ObjectClass::Menu | ObjectClass::Timer ) { return Err(RuntimeError(421)); } self.activate_form(self.root_form(key)); if self.active_control != Some(key) { self.dropdown = None; } self.active_control = Some(key); Ok(()) } pub fn modal_top(&self) -> Option { self.modal.last().copied() } pub fn screen_show(&mut self, visible: bool) { self.screen_visible = visible; self.dirty = true; } pub fn screen_visible(&self) -> bool { self.screen_visible } fn instance(&self, key: ObjectKey) -> Result<&ObjectInstance, RuntimeError> { match key.1 { Some(index) => self.dynamic.get(&(key.0, index)).ok_or(RuntimeError(340)), None => self.objects.get(key.0 as usize).ok_or(RuntimeError(420)), } } fn instance_mut(&mut self, key: ObjectKey) -> Result<&mut ObjectInstance, RuntimeError> { match key.1 { Some(index) => self .dynamic .get_mut(&(key.0, index)) .ok_or(RuntimeError(340)), None => self .objects .get_mut(key.0 as usize) .ok_or(RuntimeError(420)), } } fn value(&self, key: ObjectKey, name: &str) -> Option<&PropertyValue> { let obj = self.instance(key).ok()?; let (id, _) = forms::property(obj.description.class, name)?; obj.properties.get(id as usize) } fn integer(&self, key: ObjectKey, name: &str) -> Option { match self.value(key, name) { Some(PropertyValue::Integer(value)) => Some(*value), _ => None, } } fn boolean(&self, key: ObjectKey, name: &str) -> bool { matches!(self.value(key, name), Some(PropertyValue::Boolean(true))) } fn string(&self, key: ObjectKey, name: &str) -> String { match self.value(key, name) { Some(PropertyValue::String(value)) => value.clone(), _ => String::new(), } } fn queue_named(&mut self, key: ObjectKey, name: &str, args: Vec) { let array_index = key.1.or_else(|| { self.instance(key) .ok() .filter(|object| object.description.array) .map(|_| 0) }); self.events.push_back(FormEvent { object: key.0, array_index, name: name.into(), args, }); } fn parent_key(&self, key: ObjectKey) -> Option { match self.value(key, "PARENT") { Some(PropertyValue::Object(parent)) => { parent.map(|(id, index)| (id, index.filter(|i| *i != 0))) } _ => self .instance(key) .ok()? .description .parent .map(|id| (id, None)), } } fn root_form(&self, key: ObjectKey) -> Option { 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 } fn keys(&self) -> Vec { let mut keys: Vec<_> = (0..self.objects.len()) .map(|id| (id as u16, None)) .collect(); keys.extend(self.dynamic.keys().map(|(id, index)| (*id, Some(*index)))); keys } fn focusable(&self, key: ObjectKey) -> bool { let Ok(obj) = self.instance(key) else { return false; }; !matches!( obj.description.class, ObjectClass::Form | ObjectClass::Frame | ObjectClass::Label | ObjectClass::Menu | ObjectClass::Timer | ObjectClass::Screen ) && self.boolean(key, "ENABLED") && self.boolean(key, "VISIBLE") && self.boolean(key, "TABSTOP") && self .root_form(key) .is_some_and(|form| self.is_visible(form)) } pub fn focus(&mut self, key: ObjectKey) -> Result<(), RuntimeError> { if !self.focusable(key) { return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); } if self.active_control == Some(key) { return Ok(()); } if let Some(old) = self.active_control { self.queue_named(old, "LOSTFOCUS", vec![]); } 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; Ok(()) } pub fn tab(&mut self, forward: bool) -> bool { let Some(form) = self .active_form .or_else(|| self.visible_forms.last().copied()) else { return false; }; let mut keys: Vec<_> = self .keys() .into_iter() .filter(|key| self.root_form(*key) == Some(form) && self.focusable(*key)) .collect(); keys.sort_by_key(|key| (self.integer(*key, "TABINDEX").unwrap_or(0), key.0, key.1)); if keys.is_empty() { return false; } let current = self .active_control .and_then(|active| keys.iter().position(|key| *key == active)); let next = match (current, forward) { (Some(at), true) => (at + 1) % keys.len(), (Some(at), false) => (at + keys.len() - 1) % keys.len(), (None, true) => 0, (None, false) => keys.len() - 1, }; self.focus(keys[next]).is_ok() } fn access_key(text: &str) -> Option { let mut chars = text.chars(); while let Some(ch) = chars.next() { if ch == '&' { return chars.next().map(|c| c.to_ascii_uppercase()); } } None } fn caption(&self, key: ObjectKey) -> String { self.string(key, "CAPTION") .replace("&&", "\0") .replace('&', "") .replace('\0', "&") } fn replace_selection(&mut self, key: ObjectKey, replacement: &str) -> Result<(), RuntimeError> { let class = self.instance(key)?.description.class; let start = self.integer(key, "SELSTART").unwrap_or(0).max(0) as usize; let length = self.integer(key, "SELLENGTH").unwrap_or(0).max(0) as usize; let text = self.string(key, "TEXT"); let chars: Vec = text.chars().collect(); let start = start.min(chars.len()); let end = (start + length).min(chars.len()); let mut changed: String = chars[..start].iter().collect(); changed.push_str(replacement); changed.extend(chars[end..].iter()); let text_id = forms::property(class, "TEXT").unwrap().0 as usize; let start_id = forms::property(class, "SELSTART").unwrap().0 as usize; let length_id = forms::property(class, "SELLENGTH").unwrap().0 as usize; let obj = self.instance_mut(key)?; obj.properties[text_id] = PropertyValue::String(changed); obj.properties[start_id] = PropertyValue::Integer((start + replacement.chars().count()) as i32); obj.properties[length_id] = PropertyValue::Integer(0); self.queue_named(key, "CHANGE", vec![]); self.dirty = true; Ok(()) } fn select_option(&mut self, key: ObjectKey) -> Result<(), RuntimeError> { let parent = self.parent_key(key); let value_id = forms::property(ObjectClass::OptionButton, "VALUE") .unwrap() .0 as usize; for other in self.keys() { if other == key { continue; } let same_group = self.instance(other).is_ok_and(|obj| { obj.description.class == ObjectClass::OptionButton && self.parent_key(other) == parent }); if same_group { self.instance_mut(other)?.properties[value_id] = PropertyValue::Integer(0); } } Ok(()) } fn validate_menu_value( &self, key: ObjectKey, name: &str, value: &PropertyValue, ) -> Result<(), RuntimeError> { let separator = if name == "SEPARATOR" { matches!(value, PropertyValue::Boolean(true)) } else { self.boolean(key, "SEPARATOR") }; let invalid = separator && match name { "CHECKED" => matches!(value, PropertyValue::Boolean(true)), "ENABLED" => matches!(value, PropertyValue::Boolean(false)), "SHORTCUT" => matches!(value, PropertyValue::String(text) if !text.is_empty()), "SEPARATOR" => { self.boolean(key, "CHECKED") || !self.boolean(key, "ENABLED") || !self.string(key, "SHORTCUT").is_empty() } _ => false, }; let title_shortcut = name == "SHORTCUT" && matches!(value, PropertyValue::String(text) if !text.is_empty()) && self .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) } else { Ok(()) } } fn sort_list(&mut self, key: ObjectKey) -> Result<(), RuntimeError> { let selected = self.integer(key, "LISTINDEX").unwrap_or(-1); let Some(items) = self.lists.get_mut(&key) else { return Ok(()); }; // Ursprüngliche Indizes erhalten die Auswahl auch bei gleichen Texten. let mut indexed: Vec<_> = items.drain(..).enumerate().collect(); indexed.sort_by_cached_key(|(_, text)| text.to_uppercase()); let selected = indexed .iter() .position(|(index, _)| *index as i32 == selected) .map_or(-1, |index| index as i32); items.extend(indexed.into_iter().map(|(_, text)| text)); let obj = self.instance_mut(key)?; let property = forms::property(obj.description.class, "LISTINDEX") .unwrap() .0; obj.properties[property as usize] = PropertyValue::Integer(selected); Ok(()) } pub fn add_item( &mut self, key: ObjectKey, text: String, at: Option, ) -> Result<(), RuntimeError> { let class = self.instance(key)?.description.class; if !matches!(class, ObjectClass::ListBox | ObjectClass::ComboBox) { return Err(RuntimeError(421)); } let selected = self.integer(key, "LISTINDEX").unwrap_or(-1); let sorted = self.boolean(key, "SORTED"); let items = self.lists.entry(key).or_default(); let pos = if sorted { items.partition_point(|item| item.to_uppercase() <= text.to_uppercase()) } else { let at = at.unwrap_or(items.len() as i32); if at < 0 || at as usize > items.len() { return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); } at as usize }; items.insert(pos, text); if selected >= pos as i32 { let property = forms::property(class, "LISTINDEX").unwrap().0; self.set_at(key.0, key.1, property, PropertyValue::Integer(selected + 1))?; } self.dirty = true; Ok(()) } pub fn remove_item(&mut self, key: ObjectKey, at: i32) -> Result<(), RuntimeError> { let items = self .lists .get_mut(&key) .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; if at < 0 || at as usize >= items.len() { return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); } items.remove(at as usize); let selected = self.integer(key, "LISTINDEX").unwrap_or(-1); if selected >= at { let property = forms::property(self.instance(key)?.description.class, "LISTINDEX") .unwrap() .0; let selected = if selected == at { -1 } else { selected - 1 }; self.set_at(key.0, key.1, property, PropertyValue::Integer(selected))?; } self.dirty = true; Ok(()) } fn list_text(&self, key: ObjectKey) -> String { let selected = self.integer(key, "LISTINDEX").unwrap_or(-1); if selected >= 0 { if let Some(text) = self .lists .get(&key) .and_then(|items| items.get(selected as usize)) { return text.clone(); } } if self .instance(key) .is_ok_and(|obj| obj.description.class == ObjectClass::ComboBox) && self.integer(key, "STYLE").unwrap_or(0) != 2 { self.string(key, "TEXT") } else { String::new() } } fn wildcard(pattern: &str, name: &str) -> bool { fn matches(p: &[char], n: &[char]) -> bool { match p.split_first() { None => n.is_empty(), Some(('*', rest)) => matches(rest, n) || (!n.is_empty() && matches(p, &n[1..])), Some(('?', rest)) => !n.is_empty() && matches(rest, &n[1..]), Some((head, rest)) => { !n.is_empty() && head.eq_ignore_ascii_case(&n[0]) && matches(rest, &n[1..]) } } } matches( &pattern.chars().collect::>(), &name.chars().collect::>(), ) } pub fn refresh_filesystem(&mut self, key: ObjectKey) -> Result<(), RuntimeError> { let class = self.instance(key)?.description.class; let mut entries = Vec::new(); match class { ObjectClass::DriveListBox => { entries.push(std::path::MAIN_SEPARATOR.to_string()); #[cfg(target_os = "macos")] if let Ok(mounts) = std::fs::read_dir("/Volumes") { entries.extend( mounts .flatten() .map(|entry| entry.path().display().to_string()), ); } } ObjectClass::DirListBox | ObjectClass::FileListBox => { let path = self.string(key, "PATH"); let path = if path.is_empty() { PathBuf::from(".") } else { PathBuf::from(path) }; let pattern = self.string(key, "PATTERN"); for entry in std::fs::read_dir(&path) .map_err(|_| RuntimeError(76))? .flatten() { let is_dir = entry.file_type().is_ok_and(|ty| ty.is_dir()); let name = entry.file_name().to_string_lossy().to_string(); if (class == ObjectClass::DirListBox && is_dir) || (class == ObjectClass::FileListBox && !is_dir && Self::wildcard( if pattern.is_empty() { "*" } else { &pattern }, &name, )) { entries.push(name); } } } _ => return Err(RuntimeError(421)), } entries.sort_by_key(|entry| entry.to_uppercase()); self.lists.insert(key, entries); self.dirty = true; Ok(()) } fn text_of(value: &PropertyValue) -> String { match value { PropertyValue::String(value) => value.clone(), PropertyValue::Integer(value) => value.to_string(), PropertyValue::Single(value) => value.to_string(), PropertyValue::Boolean(value) => if *value { "-1" } else { "0" }.into(), _ => String::new(), } } pub fn object_method( &mut self, object: u16, name: &str, args: Vec, ) -> Result, RuntimeError> { self.object_method_at(object, None, name, args) } pub fn object_method_at( &mut self, object: u16, index: Option, name: &str, args: Vec, ) -> Result, RuntimeError> { self.ensure_loaded_at(object, index)?; let key = (object, index.filter(|value| *value != 0)); let class = self.instance(key)?.description.class; match name { "SETFOCUS" => self.focus(key)?, "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(), args.get(1).and_then(|value| match value { PropertyValue::Integer(v) => Some(*v), _ => None, }), )?, "REMOVEITEM" => self.remove_item( key, match args.first() { Some(PropertyValue::Integer(v)) => *v, _ => return Err(RuntimeError::TYPE_MISMATCH), }, )?, "MOVE" => { for (property, value) in ["LEFT", "TOP", "WIDTH", "HEIGHT"].into_iter().zip(args) { let PropertyValue::Integer(value) = value else { return Err(RuntimeError::TYPE_MISMATCH); }; let id = forms::property(class, property).ok_or(RuntimeError(421))?.0; self.set_at(object, index, id, PropertyValue::Integer(value))?; } } "DRAG" => { let action = match args.first() { Some(PropertyValue::Integer(v)) => *v, _ => return Err(RuntimeError::TYPE_MISMATCH), }; match action { 0 => { self.dragging = None; self.drag_over = None; } 1 => self.dragging = Some(key), 2 => self.drop_drag(None), _ => return Err(RuntimeError::ILLEGAL_FUNCTION_CALL), } } "CLS" => { self.pictures.remove(&key); self.dirty = true; } "PRINT" => { let line = args.iter().map(Self::text_of).collect::>().join(" "); self.pictures.entry(key).or_default().push(line); self.dirty = true; } "TEXTWIDTH" => { return Ok(Some(PropertyValue::Integer( args.first() .map(Self::text_of) .unwrap_or_default() .lines() .map(|line| line.chars().count()) .max() .unwrap_or(0) as i32, ))); } "TEXTHEIGHT" => { return Ok(Some(PropertyValue::Integer( args.first() .map(Self::text_of) .unwrap_or_default() .lines() .count() .max(1) as i32, ))); } _ => return Err(RuntimeError::FEATURE_UNAVAILABLE), } Ok(None) } fn activate(&mut self, key: ObjectKey) -> Result<(), RuntimeError> { let class = self.instance(key)?.description.class; if !self.boolean(key, "ENABLED") { return Ok(()); } match class { ObjectClass::CheckBox => { let id = forms::property(class, "VALUE").unwrap().0 as usize; let value = (self.integer(key, "VALUE").unwrap_or(0) + 1) % 2; self.instance_mut(key)?.properties[id] = PropertyValue::Integer(value); } ObjectClass::OptionButton => { let id = forms::property(class, "VALUE").unwrap().0 as usize; self.instance_mut(key)?.properties[id] = PropertyValue::Integer(-1); self.select_option(key)?; } ObjectClass::Spin => return Ok(()), _ => {} } self.queue_named(key, "CLICK", vec![]); self.dirty = true; Ok(()) } fn form_controls(&self, form: u16) -> Vec { 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) { // 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 { self.keys() .into_iter() .filter(|key| { self.instance(*key).is_ok_and(|object| { object.description.class == ObjectClass::Menu && self.parent_key(*key) == Some(parent) }) && self.boolean(*key, "VISIBLE") }) .collect() } fn choose_menu(&mut self, item: ObjectKey) { if !self.menu_children(item).is_empty() { self.menu_path.push(item); } else { self.queue_named(item, "CLICK", vec![]); self.menu_path.clear(); } self.dirty = true; } pub fn handle_key(&mut self, key: &str, shift: u8) -> bool { let Some(form) = self.modal_top().or(self.active_form) else { return false; }; if let Some(menu) = self.menu_path.last().copied() { if key == taste::ESC { self.menu_path.pop(); self.dirty = true; return true; } if let Some(ch) = key.chars().next().map(|ch| ch.to_ascii_uppercase()) { if let Some(item) = self.menu_children(menu).into_iter().find(|candidate| { Self::access_key(&self.string(*candidate, "CAPTION")) == Some(ch) && self.boolean(*candidate, "ENABLED") }) { self.choose_menu(item); } } return true; } if let Some(item) = self.form_controls(form).into_iter().find(|candidate| { self.instance(*candidate) .is_ok_and(|obj| obj.description.class == ObjectClass::Menu) && self.boolean(*candidate, "ENABLED") && self.boolean(*candidate, "VISIBLE") && Self::shortcut_matches(&self.string(*candidate, "SHORTCUT"), key, shift) }) { self.queue_named(item, "CLICK", vec![]); return true; } if shift & umschalt::ALT != 0 { if let Some(ch) = key.chars().next().map(|ch| ch.to_ascii_uppercase()) { if let Some(target) = self.form_controls(form).into_iter().find(|candidate| { Self::access_key(&self.string(*candidate, "CAPTION")) == Some(ch) && self.boolean(*candidate, "ENABLED") && self.boolean(*candidate, "VISIBLE") && self.instance(*candidate).is_ok_and(|obj| { obj.description.class != ObjectClass::Menu || self.parent_key(*candidate) == Some((form, None)) }) }) { if self .instance(target) .is_ok_and(|obj| obj.description.class == ObjectClass::Menu) { self.menu_path.clear(); self.menu_path.push(target); } else if matches!( self.instance(target).map(|obj| obj.description.class), Ok(ObjectClass::CommandButton | ObjectClass::CheckBox | ObjectClass::OptionButton) ) { let _ = self.activate(target); } else { let _ = self.focus(target); } self.dirty = true; return true; } } } if key == taste::TAB { return self.tab(shift & umschalt::SHIFT == 0); } if let Some(active) = self .active_control .filter(|active| self.root_form(*active) == Some(form)) { let code = key.chars().next().map_or(0, |ch| ch as i32); self.queue_named( active, "KEYDOWN", vec![ PropertyValue::Integer(code), PropertyValue::Integer(shift as i32), ], ); 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)]); } self.queue_named( active, "KEYUP", vec![ PropertyValue::Integer(code), PropertyValue::Integer(shift as i32), ], ); if handled { return true; } } let button_property = if key == taste::ENTER { "DEFAULT" } else if key == taste::ESC { "CANCEL" } else { "" }; if !button_property.is_empty() { if let Some(button) = self.form_controls(form).into_iter().find(|candidate| { self.instance(*candidate) .is_ok_and(|obj| obj.description.class == ObjectClass::CommandButton) && self.boolean(*candidate, button_property) && self.boolean(*candidate, "ENABLED") }) { let _ = self.activate(button); return true; } } let code = key.chars().next().map_or(0, |ch| ch as i32); self.queue_named( (form, None), "KEYDOWN", vec![ PropertyValue::Integer(code), 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 } fn control_key(&mut self, key: ObjectKey, input: &str) -> bool { let Ok(class) = self.instance(key).map(|obj| obj.description.class) else { return false; }; if matches!( class, ObjectClass::CommandButton | ObjectClass::CheckBox | ObjectClass::OptionButton ) && matches!(input, "\r" | " ") { let _ = self.activate(key); return true; } if class == ObjectClass::TextBox || (class == ObjectClass::ComboBox && self.integer(key, "STYLE") != Some(2)) { if input == taste::BACKSPACE { let pos = self .integer(key, "SELSTART") .unwrap_or_else(|| self.string(key, "TEXT").chars().count() as i32); if pos > 0 { let start = forms::property(class, "SELSTART").unwrap().0 as usize; let length = forms::property(class, "SELLENGTH").unwrap().0 as usize; let obj = self.instance_mut(key).unwrap(); obj.properties[start] = PropertyValue::Integer(pos - 1); obj.properties[length] = PropertyValue::Integer(1); let _ = self.replace_selection(key, ""); } return true; } if input == taste::ENTER && class == ObjectClass::TextBox && self.boolean(key, "MULTILINE") { let _ = self.replace_selection(key, "\n"); return true; } if input.chars().count() == 1 && input.chars().next().is_some_and(|ch| !ch.is_control()) { let _ = self.replace_selection(key, input); return true; } } let direction = input.chars().nth(1).map(|ch| ch as u8); if matches!(class, ObjectClass::ListBox | ObjectClass::ComboBox) && matches!(direction, Some(72 | 80)) { let count = self.lists.get(&key).map_or(0, Vec::len) as i32; if count > 0 { let current = self.integer(key, "LISTINDEX").unwrap_or(-1); let next = if direction == Some(72) { (current - 1).max(0) } else { (current + 1).min(count - 1) }; let id = forms::property(class, "LISTINDEX").unwrap().0 as usize; self.instance_mut(key).unwrap().properties[id] = PropertyValue::Integer(next); self.queue_named(key, "CLICK", vec![]); self.dirty = true; } return true; } if matches!(class, ObjectClass::HScrollBar | ObjectClass::VScrollBar) && matches!(direction, Some(72 | 75 | 77 | 80)) { let delta = if matches!(direction, Some(72 | 75)) { -1 } else { 1 } * self.integer(key, "SMALLCHANGE").unwrap_or(1); let value = (self.integer(key, "VALUE").unwrap_or(0) + delta).clamp( self.integer(key, "MIN").unwrap_or(0), self.integer(key, "MAX").unwrap_or(32767), ); let id = forms::property(class, "VALUE").unwrap().0; let _ = self.set_at(key.0, key.1, id, PropertyValue::Integer(value)); return true; } if class == ObjectClass::Spin { let delta = match (self.integer(key, "STYLE"), direction) { (Some(1), Some(75)) | (Some(0), Some(80)) => -1, (Some(1), Some(77)) | (Some(0), Some(72)) => 1, _ => return false, }; self.spin(key, delta); return true; } false } 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; 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 = 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 = self.parent_key(parent_key); } Some((left, top, width, height)) } pub fn hit_test(&self, form: u16, row: usize, col: usize) -> ObjectKey { if !self .rect((form, None)) .is_some_and(|(_, _, width, height)| width >= 3 && height >= 2) { return (form, None); } self.form_controls(form) .into_iter() .rev() .find(|key| { self.instance(*key).is_ok_and(|obj| { !matches!( obj.description.class, ObjectClass::Timer | ObjectClass::Menu ) }) && self.boolean(*key, "VISIBLE") && self.rect(*key).is_some_and(|(left, top, width, height)| { col >= left && col < left + width && row >= top && row < top + height }) }) .unwrap_or((form, None)) } pub fn handle_mouse(&mut self, event: MausEreignis) -> bool { self.handle_mouse_at(event, 0) } pub fn handle_mouse_at(&mut self, event: MausEreignis, now_ms: u64) -> bool { let Some(form) = self.modal_top().or(self.active_form) else { return false; }; if !self.menu_path.is_empty() { return true; } let target = self.hit_test(form, event.zeile, event.spalte); let name = match event.art { MausArt::Druck => "MOUSEDOWN", MausArt::Loslassen => "MOUSEUP", MausArt::Bewegung => "MOUSEMOVE", }; self.queue_named( target, name, vec![ PropertyValue::Integer(event.taste as i32), PropertyValue::Integer(event.shift as i32), PropertyValue::Single(event.spalte as f32), PropertyValue::Single(event.zeile as f32), ], ); match event.art { MausArt::Druck => { self.pressed = Some(target); if self.focusable(target) { let _ = self.focus(target); } if self.instance(target).is_ok_and(|obj| { matches!( obj.description.class, ObjectClass::HScrollBar | ObjectClass::VScrollBar ) }) { self.scroll_mouse(target, event.zeile, event.spalte); } if self .instance(target) .is_ok_and(|obj| obj.description.class == ObjectClass::Spin) { let delta = self.spin_direction(target, event.zeile, event.spalte); self.spin(target, delta); self.spin_repeat = Some((target, delta)); self.spin_last = None; } if self.integer(target, "DRAGMODE") == Some(1) { self.dragging = Some(target); } } MausArt::Bewegung if self.dragging.is_some() => { if self.drag_over != Some(target) { if let (Some(source), Some(old)) = (self.dragging, self.drag_over) { self.queue_named( old, "DRAGOVER", vec![ PropertyValue::Object(Some(source)), PropertyValue::Single(event.spalte as f32), PropertyValue::Single(event.zeile as f32), PropertyValue::Integer(1), ], ); } if let Some(source) = self.dragging { self.queue_named( target, "DRAGOVER", vec![ PropertyValue::Object(Some(source)), PropertyValue::Single(event.spalte as f32), PropertyValue::Single(event.zeile as f32), PropertyValue::Integer(0), ], ); } self.drag_over = Some(target); } else if let Some(source) = self.dragging { self.queue_named( target, "DRAGOVER", vec![ PropertyValue::Object(Some(source)), PropertyValue::Single(event.spalte as f32), PropertyValue::Single(event.zeile as f32), PropertyValue::Integer(2), ], ); } } MausArt::Loslassen => { if self.dragging.is_some() { self.drop_drag(Some((event.spalte as f32, event.zeile as f32))); } else if self.pressed == Some(target) { let _ = self.activate(target); let click = (target, event.taste, event.zeile, event.spalte, now_ms); if self.last_click.is_some_and(|previous| { previous.0 == target && previous.1 == event.taste && previous.2.abs_diff(event.zeile) <= 1 && previous.3.abs_diff(event.spalte) <= 1 && now_ms.saturating_sub(previous.4) <= 500 }) { self.queue_named(target, "DBLCLICK", vec![]); self.last_click = None; } else { self.last_click = Some(click); } } self.pressed = None; self.spin_repeat = None; self.spin_last = None; } _ => {} } true } fn scroll_mouse(&mut self, key: ObjectKey, row: usize, col: usize) { let Ok(class) = self.instance(key).map(|obj| obj.description.class) else { return; }; let Some((left, top, width, height)) = self.rect(key) else { return; }; let before = if class == ObjectClass::HScrollBar { col < left + width / 2 } else { row < top + height / 2 }; let delta = self.integer(key, "SMALLCHANGE").unwrap_or(1) * if before { -1 } else { 1 }; let value = (self.integer(key, "VALUE").unwrap_or(0) + delta).clamp( self.integer(key, "MIN").unwrap_or(0), self.integer(key, "MAX").unwrap_or(32767), ); if let Some((id, _)) = forms::property(class, "VALUE") { let _ = self.set_at(key.0, key.1, id, PropertyValue::Integer(value)); } } fn spin_direction(&self, key: ObjectKey, row: usize, col: usize) -> i32 { let Some((left, top, width, height)) = self.rect(key) else { return 1; }; if self.integer(key, "STYLE") == Some(1) { if col < left + width / 2 { -1 } else { 1 } } else if row < top + height / 2 { 1 } else { -1 } } fn spin(&mut self, key: ObjectKey, delta: i32) { let min = self.integer(key, "MIN").unwrap_or(0); let max = self.integer(key, "MAX").unwrap_or(32767); if min > max { return; } let current = self.integer(key, "VALUE").unwrap_or(min); let value = if delta > 0 && current >= max { min } else if delta < 0 && current <= min { max } else { current + delta }; if let Some((id, _)) = forms::property(ObjectClass::Spin, "VALUE") { if self .set_at(key.0, key.1, id, PropertyValue::Integer(value)) .is_ok() { self.queue_named( key, "CUSTOM", vec![PropertyValue::Integer(if delta > 0 { 1 } else { 2 })], ); } } } fn drop_drag(&mut self, position: Option<(f32, f32)>) { if let Some(source) = self.dragging.take() { if let Some(target) = self.drag_over.take() { let (x, y) = position.unwrap_or((0.0, 0.0)); self.queue_named( target, "DRAGDROP", vec![ PropertyValue::Object(Some(source)), PropertyValue::Single(x), PropertyValue::Single(y), ], ); } } } fn reset_timer(&mut self, key: ObjectKey) { self.timer_last.remove(&key); self.events.retain(|event| { event.name != "TIMER" || (event.object, event.array_index.filter(|index| *index != 0)) != key }); } fn reset_form_timers(&mut self, form: u16) { for key in self.keys() { if self .instance(key) .is_ok_and(|obj| obj.description.class == ObjectClass::Timer) && self.root_form(key) == Some(form) { self.reset_timer(key); } } } /// Nach Zustandsänderungen aktive Timer an der Hostzeit beginnen lassen. /// Die Uhr wird nur bei einer neuen aktiven Phase abgefragt, auch im Menü. pub fn sync_timers(&mut self, now: impl FnOnce() -> u64) { let starts: Vec<_> = self .keys() .into_iter() .filter(|key| self.timer_enabled(*key) && !self.timer_last.contains_key(key)) .collect(); if !starts.is_empty() { let now_ms = now(); for key in starts { self.timer_last.insert(key, now_ms); } } } fn timer_enabled(&self, key: ObjectKey) -> bool { self.instance(key) .is_ok_and(|obj| obj.description.class == ObjectClass::Timer) && self.boolean(key, "ENABLED") && self.integer(key, "INTERVAL").unwrap_or(0) > 0 && self .root_form(key) .is_some_and(|form| self.is_visible(form)) } /// Zeitbedarf für Timer und gedrückt gehaltene Spin-Schaltflächen. pub fn next_deadline(&self) -> Option { if self.menu_is_open() || (!self.has_visible_forms() && self.spin_repeat.is_none()) { return None; } let timers = self .keys() .into_iter() .filter(|key| self.timer_enabled(*key)) .map(|key| { self.timer_last .get(&key) .copied() .unwrap_or(0) .saturating_add(self.integer(key, "INTERVAL").unwrap() as u64) }); let spin = self.spin_repeat.and_then(|(key, _)| { let interval = self.integer(key, "INTERVAL").unwrap_or(250).max(0) as u64; (interval > 0).then(|| { self.spin_last .map_or(0, |last| last.saturating_add(interval)) }) }); timers.chain(spin).min() } pub fn mouse_needs_time(&self, event: MausEreignis) -> bool { self.active_form.is_some() && !self.menu_is_open() && event.art == MausArt::Loslassen && self.pressed.is_some() } pub fn timers(&mut self, now_ms: u64) { self.sync_timers(|| now_ms); if !self.menu_path.is_empty() { return; } let mut due: Vec<_> = self .keys() .into_iter() .filter(|key| self.timer_enabled(*key)) .collect(); due.sort_by_key(|key| { ( self.instance(*key) .map(|obj| obj.description.name.to_uppercase()) .unwrap_or_default(), key.1.unwrap_or(0), ) }); for key in due { let interval = self.integer(key, "INTERVAL").unwrap() as u64; let mut count = 0; { let last = self.timer_last.get_mut(&key).unwrap(); while now_ms.saturating_sub(*last) >= interval { *last += interval; count += 1; } } for _ in 0..count { self.queue_named(key, "TIMER", vec![]); } } if let Some((key, delta)) = self.spin_repeat { let interval = self.integer(key, "INTERVAL").unwrap_or(250).max(0) as u64; if interval > 0 { let last = self.spin_last.get_or_insert(now_ms); let mut count = 0; while now_ms.saturating_sub(*last) >= interval { *last += interval; count += 1; } for _ in 0..count { self.spin(key, delta); } } } } pub fn menu_is_open(&self) -> bool { !self.menu_path.is_empty() } fn shortcut_matches(shortcut: &str, key: &str, shift: u8) -> bool { if shortcut.is_empty() { return false; } let mut actual = String::new(); if shift & umschalt::CTRL != 0 { actual.push_str("CTRL+"); } if shift & umschalt::ALT != 0 { actual.push_str("ALT+"); } if shift & umschalt::SHIFT != 0 { actual.push_str("SHIFT+"); } if let Some(code) = key .strip_prefix('\0') .and_then(|tail| tail.chars().next()) .map(|ch| ch as u8) { let name = match code { 59..=68 => format!("F{}", code - 58), 133 => "F11".into(), 134 => "F12".into(), _ => return false, }; actual.push_str(&name); } else { actual.push_str(&key.to_uppercase()); } shortcut.replace(' ', "").eq_ignore_ascii_case(&actual) } fn fit(text: &str, width: usize) -> String { let mut out = String::new(); let mut remaining = width; for ch in text.chars() { let ch = if ch.is_control() { ' ' } else { ch }; // Dieselbe Zellenbreite wie TextScreen, auch für kombinierende Zeichen. let cells = ch.width().unwrap_or(1).max(1); if cells > remaining { break; } out.push(ch); remaining -= cells; } out.extend(std::iter::repeat_n(' ', remaining)); out } fn put_char(line: &mut String, at: usize, value: char) { let mut chars: Vec<_> = line.chars().collect(); if let Some(cell) = chars.get_mut(at) { *cell = value; *line = chars.into_iter().collect(); } } fn write_at(screen: &mut TextScreen, row: usize, col: usize, text: &str, fg: u8, bg: u8) { if row == 0 || col == 0 || row > screen.rows() || col > screen.cols() { return; } screen.set_color(fg, bg); let _ = screen.locate(row, col); screen.print_line(text); } fn box_lines(width: usize, height: usize, caption: &str, double: bool) -> Vec { let width = width.max(2); let height = height.max(2); let (tl, tr, bl, br, h, v) = if double { ('╔', '╗', '╚', '╝', '═', '║') } else { ('┌', '┐', '└', '┘', '─', '│') }; let mut top: Vec = std::iter::once(tl) .chain(std::iter::repeat_n(h, width - 2)) .chain(std::iter::once(tr)) .collect(); if !caption.is_empty() && width > 4 { for (at, ch) in format!(" {caption} ").chars().take(width - 2).enumerate() { top[at + 1] = ch; } } let mut lines = vec![top.into_iter().collect()]; for _ in 2..height { lines.push(format!("{v}{}{v}", " ".repeat(width - 2))); } lines.push(format!("{bl}{}{br}", h.to_string().repeat(width - 2))); lines } fn list_lines(&self, key: ObjectKey, width: usize, height: usize) -> Vec { let mut lines: Vec<_> = Self::box_lines(width, height, "", false) .into_iter() .take(height) .map(|line| Self::fit(&line, width)) .collect(); if width < 3 { return lines; } let selected = self.integer(key, "LISTINDEX").unwrap_or(-1); for (row, item) in self .lists .get(&key) .into_iter() .flatten() .take(height.saturating_sub(2)) .enumerate() { let marker = if selected == row as i32 { '>' } else { ' ' }; lines[row + 1] = format!("│{marker}{}│", Self::fit(item, width.saturating_sub(3))); } lines } fn control_lines(&self, key: ObjectKey, width: usize, height: usize) -> Vec { let Ok(class) = self.instance(key).map(|obj| obj.description.class) else { return vec![]; }; let caption = self.caption(key); match class { ObjectClass::CommandButton if height == 1 => { vec![Self::fit(&format!("<{caption}>"), width)] } ObjectClass::CommandButton if height == 2 => { let mut lines = Self::box_lines(width, 2, &caption, false); lines.truncate(2); lines } ObjectClass::CommandButton => { let mut lines = Self::box_lines(width, height, "", false); let row = height / 2; if row < lines.len() && width > 2 { let text = Self::fit(&caption, width - 2); lines[row] = format!("│{text}│"); } lines } ObjectClass::Label => { let text = match self.integer(key, "ALIGNMENT").unwrap_or(0) { 1 => format!("{caption:>width$}"), 2 => { let left = width.saturating_sub(caption.chars().count()) / 2; Self::fit(&format!("{}{caption}", " ".repeat(left)), width) } _ => Self::fit(&caption, width), }; if self.integer(key, "BORDERSTYLE").unwrap_or(0) == 0 { vec![text] } else { Self::box_lines( width, height.max(2), &caption, self.integer(key, "BORDERSTYLE") == Some(2), ) } } ObjectClass::Frame => Self::box_lines(width, height, &caption, false), ObjectClass::CheckBox => vec![Self::fit( &format!( "[{}] {caption}", match self.integer(key, "VALUE").unwrap_or(0) { 1 => "x", 2 => "-", _ => " ", } ), width, )], ObjectClass::OptionButton => vec![Self::fit( &format!( "({}) {caption}", if self.integer(key, "VALUE").is_some_and(|value| value != 0) { "•" } else { " " } ), width, )], ObjectClass::TextBox => { let text = self.string(key, "TEXT"); let border = self.integer(key, "BORDERSTYLE") != Some(0); let multiline = self.boolean(key, "MULTILINE"); let scrollbars = if multiline { self.integer(key, "SCROLLBARS").unwrap_or(0) } else { 0 }; let vertical = scrollbars & 2 != 0; let horizontal = scrollbars & 1 != 0; let inset = usize::from(border); let content_width = width .saturating_sub(inset * 2 + usize::from(vertical)) .max(1); let content_height = height.saturating_sub(inset * 2 + usize::from(horizontal)); let mut wrapped = Vec::new(); for line in text.lines().take(if multiline { usize::MAX } else { 1 }) { let chars: Vec<_> = line.chars().collect(); if chars.is_empty() { wrapped.push(String::new()); } else { wrapped.extend( chars .chunks(content_width) .map(|chunk| chunk.iter().collect::()), ); } } let mut lines = if border { Self::box_lines(width, height.max(2), "", false) } else { vec![" ".repeat(width); height] }; for (row, text) in wrapped.into_iter().take(content_height).enumerate() { for (column, value) in Self::fit(&text, content_width).chars().enumerate() { Self::put_char(&mut lines[row + inset], column + inset, value); } } if vertical && content_height > 0 { let column = width.saturating_sub(inset + 1); for row in 0..content_height { let value = if row == 0 { '▲' } else if row + 1 == content_height { '▼' } else { '│' }; Self::put_char(&mut lines[row + inset], column, value); } } if horizontal && content_width > 0 && inset + content_height < lines.len() { let row = inset + content_height; for column in 0..content_width { let value = if column == 0 { '◄' } else if column + 1 == content_width { '►' } else { '─' }; Self::put_char(&mut lines[row], column + inset, value); } } lines } ObjectClass::ListBox | ObjectClass::DirListBox | ObjectClass::FileListBox => { self.list_lines(key, width, height) } ObjectClass::ComboBox | ObjectClass::DriveListBox => { let text = if class == ObjectClass::DriveListBox { self.lists .get(&key) .and_then(|items| items.first()) .cloned() .unwrap_or_else(|| std::path::MAIN_SEPARATOR.to_string()) } else { self.list_text(key) }; let style = self.integer(key, "STYLE").unwrap_or(0); let first = if class == ObjectClass::ComboBox && style == 2 { format!("[{}]▼", Self::fit(&text, width.saturating_sub(3))) } else { format!("{text} ▼") }; let mut lines = vec![Self::fit(&first, width)]; if class == ObjectClass::ComboBox && (style == 1 || self.dropdown == Some(key)) { lines.extend(self.list_lines(key, width, height.saturating_sub(1))); } lines.truncate(height); lines } ObjectClass::HScrollBar => { let inner = width.saturating_sub(2); let min = self.integer(key, "MIN").unwrap_or(0); let max = self.integer(key, "MAX").unwrap_or(32767); let value = self.integer(key, "VALUE").unwrap_or(min); let pos = if max == min { 0 } else { ((value - min) as usize * inner.saturating_sub(1)) / (max - min) as usize }; let mut track = vec!['─'; inner]; if !track.is_empty() { let last = track.len() - 1; track[pos.min(last)] = '□'; } vec![format!("◄{}►", track.into_iter().collect::())] } ObjectClass::VScrollBar => { let mut lines = vec!["▲".into()]; lines.extend(std::iter::repeat_n("│".into(), height.saturating_sub(2))); if height > 2 { lines[height / 2] = "□".into(); } if height > 1 { lines.push("▼".into()); } lines } ObjectClass::Spin if self.integer(key, "STYLE") == Some(1) => { vec![Self::fit("◄ ►", width)] } ObjectClass::Spin => { let mut lines = vec!["▲".into()]; lines.extend(std::iter::repeat_n(" ".into(), height.saturating_sub(2))); if height > 1 { lines.push("▼".into()); } lines } ObjectClass::PictureBox => { let border = self.integer(key, "BORDERSTYLE").unwrap_or(1); let mut lines = if border == 0 { vec![" ".repeat(width); height] } else { Self::box_lines(width, height.max(2), "", border == 2) }; let offset = usize::from(border != 0); for (row, text) in self .pictures .get(&key) .into_iter() .flatten() .take(height.saturating_sub(offset * 2)) .enumerate() { let fitted = Self::fit(text, width.saturating_sub(offset * 2)); lines[row + offset] = if offset == 0 { fitted } else { format!("│{fitted}│") }; } lines } ObjectClass::Timer | ObjectClass::Menu | ObjectClass::Screen => vec![], ObjectClass::Form => Self::box_lines( width, height, &caption, self.integer(key, "BORDERSTYLE") .is_some_and(|style| matches!(style, 3 | 4)), ), } } pub fn render(&mut self, screen: &mut TextScreen) { if !self.dirty || !self.screen_visible || self.visible_forms.is_empty() { return; } let render_forms: Vec<_> = self .visible_forms .iter() .copied() .filter(|form| { self.rect((*form, None)) .is_some_and(|(_, _, width, height)| width >= 3 && height >= 2) }) .collect(); if render_forms.is_empty() { self.dirty = false; return; } let cursor = ( screen.csrlin(), screen.pos(), screen.fg, screen.bg, screen.cursor_visible, ); screen.set_color(7, 0); screen.cls(); for form in render_forms { let form_key = (form, None); if let Some((left, top, width, height)) = self.rect(form_key) { if width < 3 || height < 2 { continue; } for (row, line) in self .control_lines(form_key, width, height) .into_iter() .enumerate() { Self::write_at(screen, top + row, left, &line, 7, 0); } let mut menu_col = left + 1; for key in self.form_controls(form) { let Ok(class) = self.instance(key).map(|obj| obj.description.class) else { continue; }; 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); Self::write_at( screen, top + 1, menu_col, &caption, if focused { 15 } else if self.boolean(key, "ENABLED") { 7 } else { 8 }, if focused { 1 } else { 0 }, ); menu_col += caption.chars().count(); } continue; } if !self.boolean(key, "VISIBLE") || matches!(class, ObjectClass::Timer | ObjectClass::Menu) { continue; } let Some((left, top, width, height)) = self.rect(key) else { continue; }; let enabled = self.boolean(key, "ENABLED"); let focused = self.active_control == Some(key); let (fg, bg) = if !enabled { (8, 0) } else if focused { (15, 1) } else { ( self.integer(key, "FORECOLOR").unwrap_or(0) as u8, self.integer(key, "BACKCOLOR").unwrap_or(7) as u8, ) }; for (row, line) in self .control_lines(key, width, height) .into_iter() .enumerate() { Self::write_at(screen, top + row, left, &line, fg, bg); } } if self .menu_path .first() .is_some_and(|root| self.root_form(*root) == Some(form)) { let mut popup_left = left + 1; for root in self.form_controls(form).into_iter().filter(|key| { self.instance(*key).is_ok_and(|object| { object.description.class == ObjectClass::Menu && self.parent_key(*key) == Some((form, None)) }) && self.boolean(*key, "VISIBLE") }) { if Some(&root) == self.menu_path.first() { break; } popup_left += self.caption(root).chars().count() + 2; } let mut popup_top = top + 2; for (depth, parent) in self.menu_path.clone().into_iter().enumerate() { let children = self.menu_children(parent); if children.is_empty() { break; } let popup_width = children .iter() .map(|child| { let shortcut = self.string(*child, "SHORTCUT"); 4 + self.caption(*child).chars().count() + usize::from(!shortcut.is_empty()) * (shortcut.chars().count() + 1) }) .max() .unwrap_or(6) .max(6) .min(screen.cols().saturating_sub(popup_left).saturating_add(1)); let mut lines = Self::box_lines(popup_width, children.len() + 2, "", false); for (row, child) in children.iter().enumerate() { if self.boolean(*child, "SEPARATOR") || self.caption(*child) == "-" { lines[row + 1] = format!("├{}┤", "─".repeat(popup_width.saturating_sub(2))); continue; } let shortcut = self.string(*child, "SHORTCUT"); let marker = if self.boolean(*child, "CHECKED") { "√ " } else { " " }; let suffix = if shortcut.is_empty() { String::new() } else { format!(" {shortcut}") }; lines[row + 1] = format!( "│{}│", Self::fit( &format!("{marker}{}{suffix}", self.caption(*child)), popup_width.saturating_sub(2), ) ); } for (row, line) in lines.iter().enumerate() { let selected = self.menu_path.get(depth + 1).is_some_and(|next| { children.get(row.saturating_sub(1)) == Some(next) }); Self::write_at( screen, popup_top + row, popup_left, line, if selected { 15 } else { 7 }, if selected { 1 } else { 0 }, ); } let Some(next) = self.menu_path.get(depth + 1) else { break; }; let Some(row) = children.iter().position(|child| child == next) else { break; }; popup_left += popup_width.saturating_sub(1); popup_top += row + 1; } } } } screen.set_color(cursor.2, cursor.3); let _ = screen.locate(cursor.0.min(screen.rows()), cursor.1.min(screen.cols())); screen.cursor_visible = cursor.4; self.dirty = false; } } fn dialog_set( model: &mut FormsModel, object: u16, name: &str, value: PropertyValue, ) -> Result<(), RuntimeError> { let class = model.objects[object as usize].description.class; let property = forms::property(class, name).ok_or(RuntimeError(421))?.0; model.set_initial(object, property, value) } fn dialog_event( model: &mut FormsModel, screen: &mut TextScreen, host: &mut dyn Host, queued: &mut VecDeque, plain_access_key: bool, ) -> Option { loop { model.render(screen); host.present(screen); let event = queued .pop_front() .map(|e| e.ereignis) .or_else(|| host.next_event(true))?; match event { Ereignis::Taste(key, shift) => { let shift = if plain_access_key && key.chars().count() == 1 { shift | umschalt::ALT } else { shift }; model.handle_key(&key, shift); } Ereignis::Maus(event) => { let now = if model.mouse_needs_time(event) { host.jetzt_ms() } else { 0 }; model.handle_mouse_at(event, now); } Ereignis::Groesse { cols, rows } => { screen.resize(cols, rows); model.resize(cols, rows); } event @ (Ereignis::Abbruch | Ereignis::Ende | Ereignis::Signal(_)) => { queued.push_front(event.into()); return None; } } while let Some(event) = model.next_event() { if event.name == "CLICK" { return Some(event); } } } } /// Zeigt MSGBOX als echtes modales FormsModel-Formular. pub fn msgbox_dialog( screen: &mut TextScreen, host: &mut dyn Host, queued: &mut VecDeque, text: &str, kind: i32, title: &str, ) -> Result { let groups: &[&[(i16, &str)]] = &[ &[(1, "&OK")], &[(1, "&OK"), (2, "&Cancel")], &[(3, "&Abort"), (4, "&Retry"), (5, "&Ignore")], &[(6, "&Yes"), (7, "&No"), (2, "&Cancel")], &[(6, "&Yes"), (7, "&No")], &[(4, "&Retry"), (2, "&Cancel")], ]; let buttons = groups .get((kind & 7) as usize) .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; let default = ((kind >> 8) & 3) as usize; if default >= buttons.len() { return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); } let button_widths: Vec<_> = buttons .iter() .map(|(_, caption)| caption.chars().count() + 3) .collect(); let button_line = button_widths.iter().sum::() + buttons.len().saturating_sub(1) * 2; let width = (text .lines() .map(|line| line.chars().count()) .max() .unwrap_or(0) + 4) .max(button_line + 4) .max(title.chars().count() + 4) .min(screen.cols()); let height = (text.lines().count() + 5).min(screen.rows()); let left = (screen.cols() - width) / 2; let top = (screen.rows() - height) / 2; let mut catalog = forms::FormCatalog::default(); catalog.add("Dialog", ObjectClass::Form, None, false); catalog.add("Prompt", ObjectClass::Label, Some("Dialog"), false); for index in 0..buttons.len() { catalog.add( format!("Button{index}"), ObjectClass::CommandButton, Some("Dialog"), false, ); } let mut model = FormsModel::new(catalog.objects, screen.cols(), screen.rows()); for (name, value) in [ ("LEFT", PropertyValue::Integer(left as i32)), ("TOP", PropertyValue::Integer(top as i32)), ("WIDTH", PropertyValue::Integer(width as i32)), ("HEIGHT", PropertyValue::Integer(height as i32)), ("CAPTION", PropertyValue::String(title.into())), ] { dialog_set(&mut model, 0, name, value)?; } for (name, value) in [ ("LEFT", PropertyValue::Integer(1)), ("TOP", PropertyValue::Integer(1)), ( "WIDTH", PropertyValue::Integer(width.saturating_sub(4) as i32), ), ( "HEIGHT", PropertyValue::Integer(text.lines().count().max(1) as i32), ), ("CAPTION", PropertyValue::String(text.into())), ] { dialog_set(&mut model, 1, name, value)?; } let mut button_left = width.saturating_sub(button_line) / 2; for (index, ((_, caption), button_width)) in buttons.iter().zip(button_widths).enumerate() { let object = index as u16 + 2; for (name, value) in [ ("LEFT", PropertyValue::Integer(button_left as i32)), ( "TOP", PropertyValue::Integer(height.saturating_sub(3) as i32), ), ("WIDTH", PropertyValue::Integer(button_width as i32)), ("HEIGHT", PropertyValue::Integer(1)), ("CAPTION", PropertyValue::String((*caption).into())), ("DEFAULT", PropertyValue::Boolean(index == default)), ("CANCEL", PropertyValue::Boolean(buttons[index].0 == 2)), ] { dialog_set(&mut model, object, name, value)?; } button_left += button_width + 2; } let old = screen.clone(); model.show(0, true)?; model.focus((default as u16 + 2, None))?; model.events.clear(); let chosen = loop { let Some(event) = dialog_event(&mut model, screen, host, queued, true) else { break buttons[default].0; }; if let Some(index) = event.object.checked_sub(2).map(usize::from) { if let Some((code, _)) = buttons.get(index) { break *code; } } }; *screen = old; Ok(chosen) } /// Zeigt INPUTBOX$ als echtes modales FormsModel-Formular. pub fn inputbox_dialog( screen: &mut TextScreen, host: &mut dyn Host, queued: &mut VecDeque, prompt: &str, title: &str, initial: &str, position: Option<(i32, i32)>, ) -> Result { let width = 46usize.min(screen.cols()); let height = 16usize.min(screen.rows()); let (left, top) = position.map_or_else( || ((screen.cols() - width) / 2, (screen.rows() - height) / 2), |(x, y)| (x.max(1) as usize - 1, y.max(1) as usize - 1), ); if left + width > screen.cols() || top + height > screen.rows() { return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); } let mut catalog = forms::FormCatalog::default(); catalog.add("InputDialog", ObjectClass::Form, None, false); catalog.add("Prompt", ObjectClass::Label, Some("InputDialog"), false); catalog.add("Input", ObjectClass::TextBox, Some("InputDialog"), false); catalog.add("OK", ObjectClass::CommandButton, Some("InputDialog"), false); catalog.add( "Cancel", ObjectClass::CommandButton, Some("InputDialog"), false, ); let mut model = FormsModel::new(catalog.objects, screen.cols(), screen.rows()); for (name, value) in [ ("LEFT", PropertyValue::Integer(left as i32)), ("TOP", PropertyValue::Integer(top as i32)), ("WIDTH", PropertyValue::Integer(width as i32)), ("HEIGHT", PropertyValue::Integer(height as i32)), ("CAPTION", PropertyValue::String(title.into())), ] { dialog_set(&mut model, 0, name, value)?; } for (name, value) in [ ("LEFT", PropertyValue::Integer(1)), ("TOP", PropertyValue::Integer(1)), ("WIDTH", PropertyValue::Integer(42)), ("HEIGHT", PropertyValue::Integer(8)), ("CAPTION", PropertyValue::String(prompt.into())), ] { dialog_set(&mut model, 1, name, value)?; } for (name, value) in [ ("LEFT", PropertyValue::Integer(2)), ("TOP", PropertyValue::Integer(10)), ("WIDTH", PropertyValue::Integer(40)), ("HEIGHT", PropertyValue::Integer(1)), ("BORDERSTYLE", PropertyValue::Integer(0)), ("TEXT", PropertyValue::String(initial.into())), ] { dialog_set(&mut model, 2, name, value)?; } for (object, left, caption, default, cancel) in [(3, 27, "&OK", true, false), (4, 34, "&Cancel", false, true)] { for (name, value) in [ ("LEFT", PropertyValue::Integer(left)), ("TOP", PropertyValue::Integer(12)), ("WIDTH", PropertyValue::Integer(8)), ("HEIGHT", PropertyValue::Integer(1)), ("CAPTION", PropertyValue::String(caption.into())), ("DEFAULT", PropertyValue::Boolean(default)), ("CANCEL", PropertyValue::Boolean(cancel)), ] { dialog_set(&mut model, object, name, value)?; } } let old = screen.clone(); model.show(0, true)?; model.focus((2, None))?; model.events.clear(); let accepted = loop { let Some(event) = dialog_event(&mut model, screen, host, queued, false) else { break false; }; match event.object { 3 => break true, 4 => break false, _ => {} } }; let text = if accepted { model.string((2, None), "TEXT") } else { String::new() }; *screen = old; Ok(text) } #[cfg(test)] mod tests { use super::*; use tb_runtime::host::CaptureHost; 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 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(); 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 spin_zaehlt_zyklisch_und_wiederholt_nach_interval() { let mut catalog = forms::FormCatalog::default(); catalog.add("Form1", ObjectClass::Form, None, false); catalog.add("Spin1", ObjectClass::Spin, Some("Form1"), false); let mut model = FormsModel::new(catalog.objects, 80, 25); for (name, value) in [("MIN", 0), ("MAX", 2), ("VALUE", 2), ("INTERVAL", 50)] { let property = forms::property(ObjectClass::Spin, name).unwrap().0; model .set_initial(1, property, PropertyValue::Integer(value)) .unwrap(); } model.spin((1, None), 1); assert_eq!(model.integer((1, None), "VALUE"), Some(0)); assert_eq!( model.events.pop_back().unwrap().args, [PropertyValue::Integer(1)] ); model.spin_repeat = Some(((1, None), -1)); model.timers(100); model.timers(150); assert_eq!(model.integer((1, None), "VALUE"), Some(2)); assert_eq!( model.events.pop_back().unwrap().args, [PropertyValue::Integer(2)] ); assert_eq!(model.control_lines((1, None), 1, 2), ["▲", "▼"]); } #[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))); } fn controls_model() -> FormsModel { let mut c = forms::FormCatalog::default(); c.add("Form1", ObjectClass::Form, None, false); for (name, class) in [ ("Command1", ObjectClass::CommandButton), ("Label1", ObjectClass::Label), ("Frame1", ObjectClass::Frame), ("Check1", ObjectClass::CheckBox), ("Option1", ObjectClass::OptionButton), ("Text1", ObjectClass::TextBox), ("List1", ObjectClass::ListBox), ("Combo1", ObjectClass::ComboBox), ("HScroll1", ObjectClass::HScrollBar), ("VScroll1", ObjectClass::VScrollBar), ("Picture1", ObjectClass::PictureBox), ("Timer1", ObjectClass::Timer), ("Dir1", ObjectClass::DirListBox), ("Drive1", ObjectClass::DriveListBox), ("File1", ObjectClass::FileListBox), ("mnuDatei", ObjectClass::Menu), ] { c.add(name, class, Some("Form1"), false); } c.add("mnuOpen", ObjectClass::Menu, Some("mnuDatei"), false); c.add("Spin1", ObjectClass::Spin, Some("Form1"), false); c.add("SCREEN", ObjectClass::Screen, None, false); let mut m = FormsModel::new(c.objects, 80, 25); for (id, name, value) in [ (0, "WIDTH", PropertyValue::Integer(70)), (0, "HEIGHT", PropertyValue::Integer(24)), (1, "WIDTH", PropertyValue::Integer(8)), (1, "HEIGHT", PropertyValue::Integer(1)), (1, "CAPTION", PropertyValue::String("&OK".into())), (2, "WIDTH", PropertyValue::Integer(8)), (2, "CAPTION", PropertyValue::String("Text".into())), (3, "WIDTH", PropertyValue::Integer(10)), (3, "HEIGHT", PropertyValue::Integer(3)), (3, "CAPTION", PropertyValue::String("Rahmen".into())), (4, "WIDTH", PropertyValue::Integer(9)), (4, "CAPTION", PropertyValue::String("Wahl".into())), (4, "VALUE", PropertyValue::Integer(1)), (5, "WIDTH", PropertyValue::Integer(9)), (5, "CAPTION", PropertyValue::String("Eins".into())), (5, "VALUE", PropertyValue::Integer(-1)), (6, "WIDTH", PropertyValue::Integer(8)), (6, "HEIGHT", PropertyValue::Integer(3)), (6, "TEXT", PropertyValue::String("abc".into())), (7, "WIDTH", PropertyValue::Integer(8)), (7, "HEIGHT", PropertyValue::Integer(3)), (8, "WIDTH", PropertyValue::Integer(8)), (9, "WIDTH", PropertyValue::Integer(8)), (10, "HEIGHT", PropertyValue::Integer(5)), (11, "WIDTH", PropertyValue::Integer(8)), (11, "HEIGHT", PropertyValue::Integer(3)), (13, "WIDTH", PropertyValue::Integer(8)), (13, "HEIGHT", PropertyValue::Integer(3)), (14, "WIDTH", PropertyValue::Integer(8)), (15, "WIDTH", PropertyValue::Integer(8)), (15, "HEIGHT", PropertyValue::Integer(3)), (16, "CAPTION", PropertyValue::String("&Datei".into())), (17, "CAPTION", PropertyValue::String("&Open".into())), (18, "WIDTH", PropertyValue::Integer(1)), (18, "HEIGHT", PropertyValue::Integer(2)), ] { let class = m.objects[id].description.class; let property = forms::property(class, name).unwrap().0; m.set_initial(id as u16, property, value).unwrap(); } m } #[test] fn zeichenbilder_aller_klassen_und_buttonhoehen() { let mut m = controls_model(); m.add_item((7, None), "eins".into(), None).unwrap(); m.add_item((8, None), "wahl".into(), None).unwrap(); let selected = forms::property(ObjectClass::ComboBox, "LISTINDEX") .unwrap() .0; m.set(8, selected, PropertyValue::Integer(0)).unwrap(); m.object_method(11, "PRINT", vec![PropertyValue::String("Bild".into())]) .unwrap(); let snapshots = [ (1, " "), (2, "Text "), (3, "┌ Rahmen ┐"), (4, "[x] Wahl "), (5, "(•) Eins "), (6, "┌──────┐"), (7, "┌──────┐"), (8, "wahl ▼ "), (9, "◄□─────►"), (10, "▲"), (11, "┌──────┐"), (13, "┌──────┐"), (14, "/ ▼ "), (15, "┌──────┐"), (18, "▲"), ]; for (id, expected) in snapshots { let width = m.integer((id, None), "WIDTH").unwrap_or(1) as usize; let height = m.integer((id, None), "HEIGHT").unwrap_or(1) as usize; assert_eq!( m.control_lines((id, None), width, height)[0], expected, "{}", m.objects[id as usize].description.name ); } let width = forms::property(ObjectClass::CommandButton, "WIDTH") .unwrap() .0; let height = forms::property(ObjectClass::CommandButton, "HEIGHT") .unwrap() .0; m.set_initial(1, width, PropertyValue::Integer(8)).unwrap(); m.set_initial(1, height, PropertyValue::Integer(2)).unwrap(); assert_eq!(m.control_lines((1, None), 8, 2), ["┌ OK ──┐", "└──────┘"]); m.set_initial(1, height, PropertyValue::Integer(3)).unwrap(); assert_eq!( m.control_lines((1, None), 8, 3), ["┌──────┐", "│OK │", "└──────┘"] ); assert!(m.control_lines((12, None), 1, 1).is_empty()); assert!(m.control_lines((16, None), 1, 1).is_empty()); } #[test] fn zustandsfarben_laufen_fuer_alle_controls_ueber_denselben_zeichenpfad() { let mut m = controls_model(); for (object, name, value) in [(0, "WIDTH", 40), (0, "HEIGHT", 10)] { let class = m.objects[object].description.class; let property = forms::property(class, name).unwrap().0; m.set_initial(object as u16, property, PropertyValue::Integer(value)) .unwrap(); } let controls = [1u16, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15, 18]; for object in controls { let class = m.objects[object as usize].description.class; for (name, value) in [("LEFT", 25), ("TOP", 5)] { m.set_initial( object, forms::property(class, name).unwrap().0, PropertyValue::Integer(value), ) .unwrap(); } } m.show(0, false).unwrap(); let mut screen = TextScreen::new(); for target in controls { for object in controls { let class = m.objects[object as usize].description.class; m.set_initial( object, forms::property(class, "VISIBLE").unwrap().0, PropertyValue::Boolean(object == target), ) .unwrap(); } m.active_control = None; let class = m.objects[target as usize].description.class; let enabled = forms::property(class, "ENABLED").unwrap().0; m.set(target, enabled, PropertyValue::Boolean(true)) .unwrap(); m.render(&mut screen); assert_eq!( (screen.cell(6, 26).fg, screen.cell(6, 26).bg), (0, 7), "normal: {:?}", class ); if m.focusable((target, None)) { m.focus((target, None)).unwrap(); m.render(&mut screen); assert_eq!( (screen.cell(6, 26).fg, screen.cell(6, 26).bg), (15, 1), "fokussiert: {:?}", class ); } m.set(target, enabled, PropertyValue::Boolean(false)) .unwrap(); m.render(&mut screen); assert_eq!( (screen.cell(6, 26).fg, screen.cell(6, 26).bg), (8, 0), "deaktiviert: {:?}", class ); } } #[test] fn fokus_tab_access_default_und_eingabeprioritaet() { let mut m = controls_model(); m.show(0, false).unwrap(); m.events.clear(); let tabstop = forms::property(ObjectClass::Label, "TABINDEX").unwrap().0; m.set_initial(2, tabstop, PropertyValue::Integer(1)) .unwrap(); assert!(m.tab(true)); assert_eq!(m.active_control, Some((1, None))); assert!(m.tab(true)); let names: Vec<_> = m.events.drain(..).map(|event| event.name).collect(); assert_eq!(names, ["GOTFOCUS", "LOSTFOCUS", "GOTFOCUS"]); m.handle_key("o", umschalt::ALT); assert_eq!(m.next_event().unwrap().name, "CLICK"); let default = forms::property(ObjectClass::CommandButton, "DEFAULT") .unwrap() .0; m.set_initial(1, default, PropertyValue::Boolean(true)) .unwrap(); m.handle_key(taste::ENTER, 0); assert!(m.events.iter().any(|event| event.name == "CLICK")); m.events.clear(); m.handle_key("d", umschalt::ALT); assert!(m.menu_is_open()); let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0; m.set(12, interval, PropertyValue::Integer(100)).unwrap(); m.sync_timers(|| 0); m.timers(250); assert!(!m.events.iter().any(|event| event.name == "TIMER")); m.handle_key("o", 0); assert_eq!(m.next_event().unwrap().object, 17); assert!(!m.menu_is_open()); m.events.clear(); m.timers(250); assert_eq!( m.events .iter() .filter(|event| event.name == "TIMER") .count(), 2 ); let cancel = forms::property(ObjectClass::CommandButton, "CANCEL") .unwrap() .0; m.set_initial(1, cancel, PropertyValue::Boolean(true)) .unwrap(); m.events.clear(); m.handle_key(taste::ESC, 0); assert!(m .events .iter() .any(|event| event.object == 1 && event.name == "CLICK")); } #[test] fn tabfolge_ueberspringt_und_laeuft_rueckwaerts() { let mut m = controls_model(); m.show(0, false).unwrap(); for id in 1..m.objects.len() { let class = m.objects[id].description.class; if let Some((tabstop, _)) = forms::property(class, "TABSTOP") { m.set_initial( id as u16, tabstop, PropertyValue::Boolean(matches!(id, 1 | 6 | 7)), ) .unwrap(); } } for (id, tab_index) in [(1, 0), (6, 1), (7, 2)] { let class = m.objects[id].description.class; m.set_initial( id as u16, forms::property(class, "TABINDEX").unwrap().0, PropertyValue::Integer(tab_index), ) .unwrap(); } let tabstop = forms::property(ObjectClass::TextBox, "TABSTOP").unwrap().0; m.set_initial(6, tabstop, PropertyValue::Boolean(false)) .unwrap(); m.focus((1, None)).unwrap(); assert!(m.tab(true)); assert_eq!(m.active_control, Some((7, None))); assert!(m.tab(false)); assert_eq!(m.active_control, Some((1, None))); let enabled = forms::property(ObjectClass::CommandButton, "ENABLED") .unwrap() .0; m.set_initial(1, enabled, PropertyValue::Boolean(false)) .unwrap(); assert!(m.tab(false)); assert_ne!(m.active_control, Some((1, None))); } #[test] fn listenauswahl_bleibt_beim_einfuegen_und_entfernen_konsistent() { for (id, class, style) in [ (7, ObjectClass::ListBox, 0), (8, ObjectClass::ComboBox, 0), (8, ObjectClass::ComboBox, 1), (8, ObjectClass::ComboBox, 2), ] { for sorted in [false, true] { let mut m = controls_model(); let prop = |name| forms::property(class, name).unwrap().0; let key = (id, None); if class == ObjectClass::ComboBox { m.set_initial(id, prop("STYLE"), PropertyValue::Integer(style)) .unwrap(); } m.set_initial(id, prop("TEXT"), PropertyValue::String("frei".into())) .unwrap(); m.set(id, prop("SORTED"), PropertyValue::Boolean(sorted)) .unwrap(); m.set(id, prop("LISTINDEX"), PropertyValue::Integer(-1)) .unwrap(); for invalid in [-2, 0] { assert_eq!( m.set(id, prop("LISTINDEX"), PropertyValue::Integer(invalid)), Err(RuntimeError(5)) ); } m.add_item(key, "b".into(), None).unwrap(); m.add_item(key, "c".into(), None).unwrap(); m.set(id, prop("LISTINDEX"), PropertyValue::Integer(1)) .unwrap(); m.add_item(key, "a".into(), Some(0)).unwrap(); assert_eq!( m.get(id, prop("LISTINDEX")).unwrap(), PropertyValue::Integer(2) ); assert_eq!( m.get(id, prop("TEXT")).unwrap(), PropertyValue::String("c".into()) ); assert_eq!( m.get_indexed(id, prop("LIST"), 2).unwrap(), PropertyValue::String("c".into()) ); m.add_item(key, "z".into(), None).unwrap(); m.remove_item(key, 0).unwrap(); assert_eq!( m.get(id, prop("LISTINDEX")).unwrap(), PropertyValue::Integer(1) ); assert_eq!( m.get(id, prop("TEXT")).unwrap(), PropertyValue::String("c".into()) ); m.remove_item(key, 1).unwrap(); assert_eq!( m.get(id, prop("LISTINDEX")).unwrap(), PropertyValue::Integer(-1) ); assert_eq!( m.get(id, prop("LISTCOUNT")).unwrap(), PropertyValue::Integer(2) ); m.set(id, prop("LISTINDEX"), PropertyValue::Integer(0)) .unwrap(); m.set(id, prop("LISTINDEX"), PropertyValue::Integer(-1)) .unwrap(); let expected = if class == ObjectClass::ComboBox && style != 2 { "frei" } else { "" }; assert_eq!( m.get(id, prop("TEXT")).unwrap(), PropertyValue::String(expected.into()) ); if class == ObjectClass::ComboBox { assert_eq!(m.control_lines(key, 12, 4)[0].contains("frei"), style != 2); assert!(!m.control_lines(key, 12, 4)[0].contains('b')); } m.remove_item(key, 1).unwrap(); m.remove_item(key, 0).unwrap(); assert_eq!( m.get(id, prop("LISTCOUNT")).unwrap(), PropertyValue::Integer(0) ); assert_eq!( m.get(id, prop("TEXT")).unwrap(), PropertyValue::String(expected.into()) ); assert_eq!(m.remove_item(key, 0), Err(RuntimeError(5))); } } } #[test] fn sorted_wechsel_erhaelt_auch_die_auswahl_zwischen_duplikaten() { for (id, class, style) in [ (7, ObjectClass::ListBox, 0), (8, ObjectClass::ComboBox, 0), (8, ObjectClass::ComboBox, 1), (8, ObjectClass::ComboBox, 2), ] { for index in [None, Some(0), Some(1)] { let mut m = controls_model(); let prop = |name| forms::property(class, name).unwrap().0; if index.is_some() { m.objects[id as usize].description.array = true; } if index == Some(1) { m.load_array(id, 1).unwrap(); } if class == ObjectClass::ComboBox { m.set_initial_at(id, index, prop("STYLE"), PropertyValue::Integer(style)) .unwrap(); } m.set_initial_at( id, index, prop("TEXT"), PropertyValue::String("frei".into()), ) .unwrap(); m.set_at(id, index, prop("SORTED"), PropertyValue::Boolean(true)) .unwrap(); assert_eq!( m.get_at(id, index, prop("LISTINDEX")).unwrap(), PropertyValue::Integer(-1) ); m.set_at(id, index, prop("SORTED"), PropertyValue::Boolean(false)) .unwrap(); for text in ["b", "a", "b", "A"] { m.object_method_at( id, index, "ADDITEM", vec![PropertyValue::String(text.into())], ) .unwrap(); } m.set_at(id, index, prop("LISTINDEX"), PropertyValue::Integer(2)) .unwrap(); for initial in [false, true] { if initial { m.set_initial_at(id, index, prop("SORTED"), PropertyValue::Boolean(true)) .unwrap(); } else { m.set_at(id, index, prop("SORTED"), PropertyValue::Boolean(true)) .unwrap(); } let values: Vec<_> = (0..4) .map(|i| m.get_indexed_at(id, index, prop("LIST"), i).unwrap()) .collect(); assert_eq!( values, ["a", "A", "b", "b"].map(|s| PropertyValue::String(s.into())) ); assert_eq!( m.get_at(id, index, prop("LISTINDEX")).unwrap(), PropertyValue::Integer(3) ); assert_eq!( m.get_at(id, index, prop("TEXT")).unwrap(), PropertyValue::String("b".into()) ); } m.object_method_at( id, index, "ADDITEM", vec![PropertyValue::String("B".into())], ) .unwrap(); m.object_method_at( id, index, "ADDITEM", vec![PropertyValue::String("0".into())], ) .unwrap(); assert_eq!( m.get_at(id, index, prop("LISTINDEX")).unwrap(), PropertyValue::Integer(4) ); m.object_method_at(id, index, "REMOVEITEM", vec![PropertyValue::Integer(0)]) .unwrap(); assert_eq!( m.get_at(id, index, prop("LISTINDEX")).unwrap(), PropertyValue::Integer(3) ); m.object_method_at(id, index, "REMOVEITEM", vec![PropertyValue::Integer(3)]) .unwrap(); m.set_at(id, index, prop("SORTED"), PropertyValue::Boolean(true)) .unwrap(); assert_eq!( m.get_at(id, index, prop("LISTINDEX")).unwrap(), PropertyValue::Integer(-1) ); let expected = if class == ObjectClass::ComboBox && style != 2 { "frei" } else { "" }; assert_eq!( m.get_at(id, index, prop("TEXT")).unwrap(), PropertyValue::String(expected.into()) ); } } } #[test] fn simple_combo_zeichnet_listeninhalt_auswahl_und_begrenzung() { let mut m = controls_model(); let prop = |name| forms::property(ObjectClass::ComboBox, name).unwrap().0; for (name, value) in [ ("STYLE", PropertyValue::Integer(1)), ("WIDTH", PropertyValue::Integer(15)), ("HEIGHT", PropertyValue::Integer(5)), ("LEFT", PropertyValue::Integer(2)), ("TOP", PropertyValue::Integer(2)), ("TEXT", PropertyValue::String("frei".into())), ] { m.set_initial(8, prop(name), value).unwrap(); } // Andere Controls aus dem Zeichenbereich nehmen. for id in 1..m.objects.len() { if id == 8 { continue; } let class = m.objects[id].description.class; if let Some((visible, _)) = forms::property(class, "VISIBLE") { m.set_initial(id as u16, visible, PropertyValue::Boolean(false)) .unwrap(); } } for text in ["apfel", "birne", "unsichtbar"] { m.add_item((8, None), text.into(), None).unwrap(); } m.show(0, false).unwrap(); let mut screen = TextScreen::new(); m.render(&mut screen); let text = tb_runtime::snapshot::text(&screen); assert!(text.contains("frei")); assert!(text.contains("apfel") && text.contains("birne")); assert!(!text.contains("unsichtbar")); m.set(8, prop("LISTINDEX"), PropertyValue::Integer(1)) .unwrap(); m.render(&mut screen); assert!(tb_runtime::snapshot::text(&screen).contains("│>birne")); m.remove_item((8, None), 1).unwrap(); m.render(&mut screen); let text = tb_runtime::snapshot::text(&screen); assert!(text.contains("frei") && text.contains("unsichtbar")); assert!(!text.contains("birne") && !text.contains("│>")); let lines = m.control_lines((8, None), 6, 4); assert_eq!(lines.len(), 4); assert!(lines.iter().all(|line| line.chars().count() == 6)); assert_eq!(lines[2], "│ apf│"); for height in 1..=3 { assert_eq!(m.control_lines((8, None), 6, height).len(), height); } } #[test] fn listenrenderer_haelt_zellgrenzen_auch_an_bildschirmraendern_ein() { for (class, style) in [ (ObjectClass::ListBox, 0), (ObjectClass::ComboBox, 0), (ObjectClass::ComboBox, 1), (ObjectClass::ComboBox, 2), (ObjectClass::DirListBox, 0), (ObjectClass::FileListBox, 0), (ObjectClass::DriveListBox, 0), ] { for index in [None, Some(1)] { for width in [1, 2, 3, 6, 15, 254] { for height in [1, 2, 3, 5, 254] { for (left, top) in [(2, 2), (78, 23), (79, 24), (254, 254)] { let mut catalog = forms::FormCatalog::default(); catalog.add("Form1", ObjectClass::Form, None, false); catalog.add("List1", class, Some("Form1"), index.is_some()); let mut m = FormsModel::new(catalog.objects, 80, 25); for (name, value) in [("WIDTH", 40), ("HEIGHT", 12)] { let prop = forms::property(ObjectClass::Form, name).unwrap().0; m.set_initial(0, prop, PropertyValue::Integer(value)) .unwrap(); } let prop = |name| forms::property(class, name).unwrap().0; m.set_initial(1, prop("VISIBLE"), PropertyValue::Boolean(false)) .unwrap(); if let Some(index) = index { m.load_array(1, index).unwrap(); } for (name, value) in [ ("WIDTH", width), ("HEIGHT", height), ("LEFT", left), ("TOP", top), ] { m.set_initial_at( 1, index, prop(name), PropertyValue::Integer(value), ) .unwrap(); } if class == ObjectClass::ComboBox { m.set_initial_at( 1, index, prop("STYLE"), PropertyValue::Integer(style), ) .unwrap(); } let key = (1, index); m.show(0, false).unwrap(); let mut screen = TextScreen::new(); m.render(&mut screen); let before = screen.clone(); for items in [ vec![], vec!["中🙂ab\ncd\ref".into(), "ein sehr langer Eintrag".into()], ] { // Auch die Dateilisten erhalten kontrollierte Inhalte ohne Dateisystemzugriff. m.lists.insert(key, items); m.set_at(1, index, prop("VISIBLE"), PropertyValue::Boolean(true)) .unwrap(); if class == ObjectClass::ComboBox { m.set_at( 1, index, prop("TEXT"), PropertyValue::String("中🙂ab\ncd\ref".into()), ) .unwrap(); } m.render(&mut screen); let mut changed = false; for row in 1..=screen.rows() { for col in 1..=screen.cols() { if row > top as usize && row <= (top + height) as usize && col > left as usize && col <= (left + width) as usize { changed |= screen.cell(row, col) != before.cell(row, col); } else { assert_eq!(screen.cell(row, col), before.cell(row, col), "{class:?} style={style} index={index:?} {width}x{height} at {left},{top}: outside cell {col},{row}"); } } } // Am rechten Rand kann ein breites erstes Zeichen ganz entfallen. if left < 78 && top < 25 { assert!( changed, "sichtbares Control muss tatsächlich gezeichnet werden" ); } } } } } } } } #[test] fn timer_neustart_verwirft_alte_fristen_und_ereignisse() { let mut m = controls_model(); m.show(0, false).unwrap(); m.events.clear(); let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0; let enabled = forms::property(ObjectClass::Timer, "ENABLED").unwrap().0; m.set(12, interval, PropertyValue::Integer(100)).unwrap(); m.sync_timers(|| 1000); assert_eq!(m.next_deadline(), Some(1100)); m.timers(1099); assert!(m.events.is_empty()); m.timers(1100); assert_eq!(m.events.drain(..).count(), 1); m.timers(1350); assert_eq!(m.events.len(), 2); m.set(12, interval, PropertyValue::Integer(200)).unwrap(); m.sync_timers(|| 1350); assert!(m.events.is_empty()); m.timers(1549); assert!(m.events.is_empty()); m.timers(1550); assert_eq!(m.events.len(), 1); m.set(12, enabled, PropertyValue::Boolean(false)).unwrap(); assert!(m.events.is_empty()); assert_eq!(m.next_deadline(), None); m.set(12, enabled, PropertyValue::Boolean(true)).unwrap(); m.sync_timers(|| 2000); m.set(12, enabled, PropertyValue::Boolean(true)).unwrap(); m.sync_timers(|| panic!("Unverändertes Enabled startet keine neue Phase")); assert_eq!(m.next_deadline(), Some(2200)); m.timers(2200); assert_eq!(m.events.len(), 1); m.set(12, interval, PropertyValue::Integer(0)).unwrap(); assert!(m.events.is_empty()); assert_eq!(m.next_deadline(), None); m.sync_timers(|| panic!("Inaktiver Timer braucht keine Uhr")); } #[test] fn timerarrays_holen_nur_aktive_zeit_nach_und_sortieren_nach_name_und_index() { for _ in 0..2 { let mut catalog = forms::FormCatalog::default(); catalog.add("Form1", ObjectClass::Form, None, false); catalog.add("TimerZ", ObjectClass::Timer, Some("Form1"), false); catalog.add("TimerA", ObjectClass::Timer, Some("Form1"), true); let mut m = FormsModel::new(catalog.objects, 80, 25); let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0; m.set(1, interval, PropertyValue::Integer(100)).unwrap(); m.set(2, interval, PropertyValue::Integer(100)).unwrap(); m.load_array(2, 2).unwrap(); m.load_array(2, 1).unwrap(); m.show(0, false).unwrap(); m.sync_timers(|| 1000); m.events.clear(); m.timers(1250); let order: Vec<_> = m.events.iter().map(|e| (e.object, e.array_index)).collect(); assert_eq!( order, [ (2, Some(0)), (2, Some(0)), (2, Some(1)), (2, Some(1)), (2, Some(2)), (2, Some(2)), (1, None), (1, None) ] ); m.set_at(2, Some(0), interval, PropertyValue::Integer(0)) .unwrap(); assert!(!m.events.iter().any(|e| e.array_index == Some(0))); assert_eq!(m.events.len(), 6); m.unload_array(2, 1).unwrap(); assert!(!m.events.iter().any(|e| e.array_index == Some(1))); m.hide(0).unwrap(); 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)); } } #[test] fn text_liste_option_scroll_picture_und_dateisystem() { let mut m = controls_model(); let text = forms::property(ObjectClass::TextBox, "TEXT").unwrap().0; let start = forms::property(ObjectClass::TextBox, "SELSTART").unwrap().0; let length = forms::property(ObjectClass::TextBox, "SELLENGTH") .unwrap() .0; let selected = forms::property(ObjectClass::TextBox, "SELTEXT").unwrap().0; m.set(6, text, PropertyValue::String("abcd".into())) .unwrap(); m.set(6, start, PropertyValue::Integer(1)).unwrap(); m.set(6, length, PropertyValue::Integer(2)).unwrap(); assert_eq!( m.get(6, selected).unwrap(), PropertyValue::String("bc".into()) ); m.set(6, selected, PropertyValue::String("X".into())) .unwrap(); assert_eq!(m.get(6, text).unwrap(), PropertyValue::String("aXd".into())); let multiline = forms::property(ObjectClass::TextBox, "MULTILINE") .unwrap() .0; let scrollbars = forms::property(ObjectClass::TextBox, "SCROLLBARS") .unwrap() .0; m.set_initial(6, multiline, PropertyValue::Boolean(true)) .unwrap(); m.set(6, scrollbars, PropertyValue::Integer(3)).unwrap(); m.set(6, text, PropertyValue::String("abcdefghijk".into())) .unwrap(); let text_lines = m.control_lines((6, None), 8, 5); assert_eq!(text_lines[1], "│abcde▲│"); assert_eq!(text_lines[2], "│fghij▼│"); assert_eq!(text_lines[3], "│◄───► │"); let sorted = forms::property(ObjectClass::ListBox, "SORTED").unwrap().0; m.set(7, sorted, PropertyValue::Boolean(true)).unwrap(); m.add_item((7, None), "b".into(), None).unwrap(); m.add_item((7, None), "a".into(), None).unwrap(); assert_eq!( m.get_indexed( 7, forms::property(ObjectClass::ListBox, "LIST").unwrap().0, 0 ) .unwrap(), PropertyValue::String("a".into()) ); assert_eq!( m.get( 7, forms::property(ObjectClass::ListBox, "LISTCOUNT") .unwrap() .0 ) .unwrap(), PropertyValue::Integer(2) ); assert_eq!( m.get( 7, forms::property(ObjectClass::ListBox, "LISTINDEX") .unwrap() .0 ) .unwrap(), PropertyValue::Integer(-1) ); let style = forms::property(ObjectClass::ComboBox, "STYLE").unwrap().0; let combo = [0, 1, 2].map(|value| { m.set_initial(8, style, PropertyValue::Integer(value)) .unwrap(); m.control_lines((8, None), 8, 4) }); assert_eq!(combo[0].len(), 1); assert!(combo[1].len() > 1); assert!(combo[2][0].starts_with('[')); let value = forms::property(ObjectClass::HScrollBar, "VALUE").unwrap().0; m.set(9, value, PropertyValue::Integer(10)).unwrap(); m.set_active_control(9, None).unwrap(); m.control_key((9, None), &taste::sonder(77)); assert_eq!(m.get(9, value).unwrap(), PropertyValue::Integer(11)); m.scroll_mouse((9, None), 1, 8); assert_eq!(m.get(9, value).unwrap(), PropertyValue::Integer(12)); assert_eq!( m.object_method(11, "TEXTWIDTH", vec![PropertyValue::String("abc".into())]) .unwrap(), Some(PropertyValue::Integer(3)) ); m.object_method(11, "PRINT", vec![PropertyValue::String("abc".into())]) .unwrap(); assert_eq!(m.pictures[&(11, None)], ["abc"]); let dir = std::env::temp_dir().join(format!("tb_forms_{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("unter")).unwrap(); std::fs::write(dir.join("a.bas"), "").unwrap(); std::fs::write(dir.join("b.txt"), "").unwrap(); let path = forms::property(ObjectClass::FileListBox, "PATH").unwrap().0; let pattern = forms::property(ObjectClass::FileListBox, "PATTERN") .unwrap() .0; m.set(15, path, PropertyValue::String(dir.display().to_string())) .unwrap(); m.set(15, pattern, PropertyValue::String("*.bas".into())) .unwrap(); assert_eq!(m.lists[&(15, None)], ["a.bas"]); let dir_path = forms::property(ObjectClass::DirListBox, "PATH").unwrap().0; m.set( 13, dir_path, PropertyValue::String(dir.display().to_string()), ) .unwrap(); assert_eq!(m.lists[&(13, None)], ["unter"]); let drive = forms::property(ObjectClass::DriveListBox, "DRIVE") .unwrap() .0; m.set(14, drive, PropertyValue::String("/".into())).unwrap(); assert!(!m.lists[&(14, None)].is_empty()); assert!(m .events .iter() .any(|event| event.object == 13 && event.name == "PATHCHANGE")); assert!(m .events .iter() .any(|event| event.object == 14 && event.name == "CHANGE")); assert!(m .events .iter() .any(|event| event.object == 15 && event.name == "PATTERNCHANGE")); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn optionen_gruppieren_nach_unmittelbarem_container() { let mut catalog = forms::FormCatalog::default(); catalog.add("Form1", ObjectClass::Form, None, false); catalog.add("Frame1", ObjectClass::Frame, Some("Form1"), false); catalog.add("Picture1", ObjectClass::PictureBox, Some("Form1"), false); catalog.add("A1", ObjectClass::OptionButton, Some("Frame1"), false); catalog.add("A2", ObjectClass::OptionButton, Some("Frame1"), false); catalog.add("B1", ObjectClass::OptionButton, Some("Picture1"), false); let mut m = FormsModel::new(catalog.objects, 80, 25); let value = forms::property(ObjectClass::OptionButton, "VALUE") .unwrap() .0; m.set(3, value, PropertyValue::Integer(-1)).unwrap(); m.set(5, value, PropertyValue::Integer(-1)).unwrap(); m.set(4, value, PropertyValue::Integer(-1)).unwrap(); assert_eq!(m.get(3, value).unwrap(), PropertyValue::Integer(0)); assert_eq!(m.get(4, value).unwrap(), PropertyValue::Integer(-1)); assert_eq!(m.get(5, value).unwrap(), PropertyValue::Integer(-1)); } #[test] fn menuregeln_weisen_separator_und_titel_shortcuts_ab() { let mut m = controls_model(); let shortcut = forms::property(ObjectClass::Menu, "SHORTCUT").unwrap().0; assert_eq!( m.set(16, shortcut, PropertyValue::String("F3".into())), Err(RuntimeError::ILLEGAL_FUNCTION_CALL) ); let separator = forms::property(ObjectClass::Menu, "SEPARATOR").unwrap().0; m.set(17, separator, PropertyValue::Boolean(true)).unwrap(); assert_eq!( m.set(17, shortcut, PropertyValue::String("F3".into())), Err(RuntimeError::ILLEGAL_FUNCTION_CALL) ); let checked = forms::property(ObjectClass::Menu, "CHECKED").unwrap().0; assert_eq!( m.set(17, checked, PropertyValue::Boolean(true)), Err(RuntimeError::ILLEGAL_FUNCTION_CALL) ); } #[test] fn menues_zeichnen_und_navigieren_sechs_ebenen() { let mut catalog = forms::FormCatalog::default(); catalog.add("Form1", ObjectClass::Form, None, false); for (name, parent) in [ ("MenuA", "Form1"), ("MenuB", "MenuA"), ("MenuC", "MenuB"), ("MenuD", "MenuC"), ("MenuE", "MenuD"), ("MenuF", "MenuE"), ] { catalog.add(name, ObjectClass::Menu, Some(parent), false); } catalog.add("Separator", ObjectClass::Menu, Some("MenuE"), false); catalog.add("Disabled", ObjectClass::Menu, Some("MenuE"), false); catalog.add("Hidden", ObjectClass::Menu, Some("MenuE"), false); let mut m = FormsModel::new(catalog.objects, 80, 25); for (id, caption) in ["&A", "&B", "&C", "&D", "&E", "&F"].into_iter().enumerate() { m.set_initial( id as u16 + 1, forms::property(ObjectClass::Menu, "CAPTION").unwrap().0, PropertyValue::String(caption.into()), ) .unwrap(); } for (object, name, value) in [ (6, "CHECKED", PropertyValue::Boolean(true)), (6, "SHORTCUT", PropertyValue::String("F3".into())), (7, "SEPARATOR", PropertyValue::Boolean(true)), (8, "CAPTION", PropertyValue::String("&Gesperrt".into())), (8, "ENABLED", PropertyValue::Boolean(false)), (9, "CAPTION", PropertyValue::String("Versteckt".into())), (9, "VISIBLE", PropertyValue::Boolean(false)), ] { m.set_initial( object, forms::property(ObjectClass::Menu, name).unwrap().0, value, ) .unwrap(); } for (name, value) in [("WIDTH", 78), ("HEIGHT", 24)] { m.set_initial( 0, forms::property(ObjectClass::Form, name).unwrap().0, PropertyValue::Integer(value), ) .unwrap(); } m.show(0, false).unwrap(); m.events.clear(); assert!(m.handle_key("a", umschalt::ALT)); for key in ["b", "c", "d", "e"] { assert!(m.handle_key(key, 0)); } assert_eq!(m.menu_path.len(), 5); let mut screen = TextScreen::new(); m.render(&mut screen); let snapshot = tb_runtime::snapshot::text(&screen); assert!(snapshot.contains("√ F F3")); assert!(snapshot.contains('├')); assert!(snapshot.contains("Gesperrt")); assert!(!snapshot.contains("Versteckt")); assert!(m.handle_key("g", 0)); assert_eq!(m.menu_path.len(), 5); assert!(m.handle_key("f", 0)); assert!(!m.menu_is_open()); let event = m.next_event().unwrap(); assert_eq!((event.object, event.name.as_str()), (6, "CLICK")); } #[test] fn z_reihenfolge_dragdrop_und_timerreihenfolge() { let mut m = controls_model(); m.show(0, false).unwrap(); m.events.clear(); for id in [1usize, 2] { for (name, value) in [("LEFT", 2), ("TOP", 2), ("WIDTH", 8), ("HEIGHT", 2)] { let property = forms::property(m.objects[id].description.class, name) .unwrap() .0; m.set_initial(id as u16, property, PropertyValue::Integer(value)) .unwrap(); } } assert_eq!(m.hit_test(0, 4, 4), (2, None)); assert_eq!(m.hit_test(0, 20, 60), (0, None)); let left = forms::property(ObjectClass::Label, "LEFT").unwrap().0; m.set_initial(2, left, PropertyValue::Integer(20)).unwrap(); let drag = forms::property(ObjectClass::CommandButton, "DRAGMODE") .unwrap() .0; m.set(1, drag, PropertyValue::Integer(1)).unwrap(); m.handle_mouse(MausEreignis { art: MausArt::Druck, taste: 1, shift: 0, zeile: 4, spalte: 4, }); m.handle_mouse(MausEreignis { art: MausArt::Bewegung, taste: 1, shift: 0, zeile: 4, spalte: 22, }); m.handle_mouse(MausEreignis { art: MausArt::Bewegung, taste: 1, shift: 0, zeile: 4, spalte: 22, }); m.handle_mouse(MausEreignis { art: MausArt::Loslassen, taste: 1, shift: 0, zeile: 4, spalte: 22, }); let drag_events: Vec<_> = m .events .iter() .filter(|event| matches!(event.name.as_str(), "DRAGOVER" | "DRAGDROP")) .collect(); assert_eq!(drag_events.len(), 3); assert_eq!(drag_events[0].object, 2); assert_eq!(drag_events[0].name, "DRAGOVER"); assert_eq!(drag_events[0].args[3], PropertyValue::Integer(0)); assert_eq!(drag_events[1].args[3], PropertyValue::Integer(2)); assert_eq!(drag_events[2].name, "DRAGDROP"); assert_eq!( drag_events[2].args[0], PropertyValue::Object(Some((1, None))) ); m.events.clear(); m.object_method(1, "DRAG", vec![PropertyValue::Integer(1)]) .unwrap(); assert_eq!(m.dragging, Some((1, None))); m.handle_mouse(MausEreignis { art: MausArt::Bewegung, taste: 1, shift: 0, zeile: 1, spalte: 22, }); m.object_method(1, "DRAG", vec![PropertyValue::Integer(2)]) .unwrap(); assert!(m.events.iter().any(|event| event.name == "DRAGDROP")); m.events.clear(); m.object_method(1, "DRAG", vec![PropertyValue::Integer(1)]) .unwrap(); m.object_method(1, "DRAG", vec![PropertyValue::Integer(0)]) .unwrap(); assert_eq!(m.dragging, None); let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0; m.set(12, interval, PropertyValue::Integer(100)).unwrap(); m.sync_timers(|| 0); m.timers(250); assert_eq!( m.events .iter() .filter(|event| event.name == "TIMER") .count(), 2 ); m.events.clear(); m.set(12, interval, PropertyValue::Integer(0)).unwrap(); m.timers(500); assert!(!m.events.iter().any(|event| event.name == "TIMER")); } #[test] fn freier_formularklick_und_doppelklick_werden_zugestellt() { let mut m = controls_model(); m.show(0, false).unwrap(); m.events.clear(); let click = |art, now_ms, model: &mut FormsModel| { model.handle_mouse_at( MausEreignis { art, taste: 1, shift: 0, zeile: 20, spalte: 60, }, now_ms, ); }; click(MausArt::Druck, 100, &mut m); click(MausArt::Loslassen, 110, &mut m); click(MausArt::Druck, 300, &mut m); click(MausArt::Loslassen, 310, &mut m); let names: Vec<_> = m .events .iter() .filter(|event| event.object == 0) .map(|event| event.name.as_str()) .collect(); assert_eq!( names, [ "MOUSEDOWN", "MOUSEUP", "CLICK", "MOUSEDOWN", "MOUSEUP", "CLICK", "DBLCLICK" ] ); } #[test] fn gleichzeitig_faellige_timer_laufen_nach_name() { let mut catalog = forms::FormCatalog::default(); catalog.add("Form1", ObjectClass::Form, None, false); catalog.add("TimerZ", ObjectClass::Timer, Some("Form1"), false); catalog.add("TimerA", ObjectClass::Timer, Some("Form1"), false); let mut m = FormsModel::new(catalog.objects, 80, 25); let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0; m.set(1, interval, PropertyValue::Integer(100)).unwrap(); m.set(2, interval, PropertyValue::Integer(100)).unwrap(); m.show(0, false).unwrap(); m.sync_timers(|| 0); m.events.clear(); m.timers(250); let names: Vec<_> = m .events .iter() .map(|event| m.objects[event.object as usize].description.name.as_str()) .collect(); assert_eq!(names, ["TIMERA", "TIMERA", "TIMERZ", "TIMERZ"]); } #[test] fn forms_dialoge_liefern_gruppen_eingabe_und_feste_groesse() { for (kind, expected) in [ (0, 1), (1 | 256, 2), (2 | 512, 5), (3, 6), (4 | 256, 7), (5, 4), ] { let mut screen = TextScreen::new(); let mut host = CaptureHost::default(); let mut queued = VecDeque::new(); host.ereignis(Ereignis::Taste(taste::ENTER.into(), 0)); assert_eq!( msgbox_dialog(&mut screen, &mut host, &mut queued, "Frage", kind, "Titel").unwrap(), expected ); } let mut screen = TextScreen::new(); let mut host = CaptureHost::default(); let mut queued = VecDeque::new(); host.ereignis(Ereignis::Taste("A".into(), 0)); host.ereignis(Ereignis::Taste(taste::ENTER.into(), 0)); assert_eq!( inputbox_dialog( &mut screen, &mut host, &mut queued, "Eingabe", "Titel", "", None ) .unwrap(), "A" ); let dialog = host.screen.unwrap(); let top = (dialog.rows() - 16) / 2 + 1; let left = (dialog.cols() - 46) / 2 + 1; assert_eq!(dialog.cell(top, left).ch, '┌'); assert_eq!(dialog.cell(top + 15, left + 45).ch, '┘'); let mut host = CaptureHost::default(); let mut queued = VecDeque::new(); host.ereignis(Ereignis::Taste(taste::ESC.into(), 0)); assert_eq!( inputbox_dialog( &mut screen, &mut host, &mut queued, "Eingabe", "Titel", "vorbelegt", None ) .unwrap(), "" ); } }