//! 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<i32>);

#[derive(Debug, Clone, PartialEq)]
pub enum PropertyValue {
    Integer(i32),
    Single(f32),
    String(String),
    Boolean(bool),
    Object(Option<(u16, Option<i32>)>),
    IntegerArray(Vec<i32>),
}

impl PropertyValue {
    pub 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<PropertyValue>,
    pub loaded: bool,
    pub visible: bool,
    pub design_time: bool,
    pub array_index: Option<i32>,
}

#[derive(Debug, Clone, PartialEq)]
pub struct FormEvent {
    pub object: u16,
    pub array_index: Option<i32>,
    pub name: String,
    pub args: Vec<PropertyValue>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShowResult {
    Modeless,
    ModalWait,
}

/// Zustandsmodell, Bedienung und Darstellung der Forms-Objekte.
pub struct FormsModel {
    pub objects: Vec<ObjectInstance>,
    dynamic: BTreeMap<(u16, i32), ObjectInstance>,
    pub events: VecDeque<FormEvent>,
    modal: Vec<u16>,
    visible_forms: Vec<u16>,
    active_form: Option<u16>,
    active_control: Option<(u16, Option<i32>)>,
    dropdown: Option<ObjectKey>,
    width: usize,
    height: usize,
    screen_visible: bool,
    lists: BTreeMap<ObjectKey, Vec<String>>,
    pictures: BTreeMap<ObjectKey, Vec<String>>,
    timer_last: BTreeMap<ObjectKey, u64>,
    pressed: Option<ObjectKey>,
    spin_repeat: Option<(ObjectKey, i32)>,
    spin_last: Option<u64>,
    dragging: Option<ObjectKey>,
    drag_over: Option<ObjectKey>,
    menu_path: Vec<ObjectKey>,
    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<u16>) {
        if self.active_form == form {
            return;
        }
        if let Some(old) = self.active_form {
            self.queue_named((old, None), "LOSTFOCUS", vec![]);
        }
        self.active_form = form;
        self.dropdown = None;
        if let Some(new) = form {
            self.queue_named((new, None), "GOTFOCUS", vec![]);
        }
    }

    pub fn new(objects: Vec<FormObject>, width: usize, height: usize) -> Self {
        let objects = objects
            .into_iter()
            .map(|description| {
                let 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<PropertyValue, RuntimeError> {
        self.get_at(object, None, property)
    }

    pub fn get_at(
        &mut self,
        object: u16,
        index: Option<i32>,
        property: u16,
    ) -> Result<PropertyValue, RuntimeError> {
        self.ensure_loaded_at(object, index)?;
        let obj = if index.is_some_and(|i| i != 0) {
            self.dynamic
                .get(&(object, index.unwrap()))
                .ok_or(RuntimeError(340))?
        } else {
            self.objects.get(object as usize).ok_or(RuntimeError(420))?
        };
        let spec = forms::properties(obj.description.class)
            .get(property as usize)
            .copied()
            .ok_or(RuntimeError(422))?;
        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<PropertyValue, RuntimeError> {
        self.get_indexed_at(object, None, property, index)
    }

    pub fn get_indexed_at(
        &mut self,
        object: u16,
        object_index: Option<i32>,
        property: u16,
        index: i32,
    ) -> Result<PropertyValue, RuntimeError> {
        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<i32>,
        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<i32>,
        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<i32>,
        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<i32>,
    ) -> Result<(), RuntimeError> {
        if index.is_some_and(|i| i != 0) {
            self.dynamic
                .get(&(object, index.unwrap()))
                .map(|_| ())
                .ok_or(RuntimeError(340))
        } else {
            self.ensure_loaded(object)
        }
    }

    pub fn show(&mut self, object: u16, modal: bool) -> Result<ShowResult, RuntimeError> {
        self.ensure_loaded(object)?;
        let obj = self
            .objects
            .get_mut(object as usize)
            .ok_or(RuntimeError(420))?;
        if obj.description.class != ObjectClass::Form {
            return Err(RuntimeError(421));
        }
        if modal {
            if obj.visible {
                return Err(RuntimeError(400));
            }
            let form_type = forms::property(ObjectClass::Form, "FORMTYPE").unwrap().0 as usize;
            if obj.properties.get(form_type) == Some(&PropertyValue::Integer(1)) {
                return Err(RuntimeError(403));
            }
            self.modal.push(object);
        } else if !self.modal.is_empty() {
            return Err(RuntimeError(401));
        }
        obj.visible = true;
        Self::set_visible_property(obj, true);
        self.visible_forms.retain(|id| *id != object);
        self.visible_forms.push(object);
        self.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<bool, RuntimeError> {
        if self.modal.contains(&object) && self.modal.last().copied() != Some(object) {
            return Err(RuntimeError(402));
        }
        let class = self
            .objects
            .get(object as usize)
            .ok_or(RuntimeError(420))?
            .description
            .class;
        if class != ObjectClass::Form {
            return Err(RuntimeError(361));
        }
        let mut cancel = 0i16;
        handler(&mut cancel);
        if cancel != 0 {
            return Ok(false);
        }
        let obj = &mut self.objects[object as usize];
        obj.visible = false;
        Self::set_visible_property(obj, false);
        obj.loaded = false;
        self.visible_forms.retain(|id| *id != object);
        if self.modal.last().copied() == Some(object) {
            self.modal.pop();
        }
        if self.active_form == Some(object) {
            self.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<FormEvent> {
        self.events.pop_front()
    }
    pub fn is_visible(&self, object: u16) -> bool {
        self.objects.get(object as usize).is_some_and(|o| o.visible)
    }
    pub fn 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<i32>) -> bool {
        if index.is_some_and(|i| i != 0) {
            self.dynamic.contains_key(&(object, index.unwrap()))
        } else {
            self.is_loaded(object)
        }
    }
    pub fn active_form(&self) -> Option<u16> {
        self.active_form
    }
    pub fn set_active_control(
        &mut self,
        object: u16,
        index: Option<i32>,
    ) -> Result<(), RuntimeError> {
        self.ensure_loaded_at(object, index)?;
        let 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<u16> {
        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<i32> {
        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<PropertyValue>) {
        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<ObjectKey> {
        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<u16> {
        let mut current = key;
        for _ in 0..self.objects.len() + self.dynamic.len() {
            let obj = self.instance(current).ok()?;
            if obj.description.class == ObjectClass::Form {
                return Some(current.0);
            }
            current = self.parent_key(current)?;
        }
        None
    }

    fn keys(&self) -> Vec<ObjectKey> {
        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<char> {
        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<char> = 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<i32>,
    ) -> 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::<Vec<_>>(),
            &name.chars().collect::<Vec<_>>(),
        )
    }

    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<PropertyValue>,
    ) -> Result<Option<PropertyValue>, RuntimeError> {
        self.object_method_at(object, None, name, args)
    }

    pub fn object_method_at(
        &mut self,
        object: u16,
        index: Option<i32>,
        name: &str,
        args: Vec<PropertyValue>,
    ) -> Result<Option<PropertyValue>, 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::<Vec<_>>().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<ObjectKey> {
        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<ObjectKey> {
        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
    }

    pub fn rect(&self, key: ObjectKey) -> Option<(usize, usize, usize, usize)> {
        self.instance(key).ok()?;
        let mut left = self.integer(key, "LEFT").unwrap_or(0).max(0) as usize + 1;
        let mut top = self.integer(key, "TOP").unwrap_or(0).max(0) as usize + 1;
        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<u64> {
        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<String> {
        let width = width.max(2);
        let height = height.max(2);
        let (tl, tr, bl, br, h, v) = if double {
            ('╔', '╗', '╚', '╝', '═', '║')
        } else {
            ('┌', '┐', '└', '┘', '─', '│')
        };
        let mut top: Vec<char> = 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<String> {
        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<String> {
        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::<String>()),
                        );
                    }
                }
                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::<String>())]
            }
            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)
}

/// Fortsetzbarer modaler Dialog; Modell, Fokus und Hintergrund bleiben erhalten.
pub struct Dialog {
    model: FormsModel,
    old: TextScreen,
    buttons: Vec<i16>,
    default: i16,
}
#[derive(Debug, PartialEq)]
pub enum DialogValue {
    Number(i16),
    Text(String),
}
impl Dialog {
    pub fn poll(
        &mut self,
        screen: &mut TextScreen,
        host: &mut dyn Host,
        queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
    ) -> Option<DialogValue> {
        if (self.old.cols(), self.old.rows()) != (screen.cols(), screen.rows()) {
            self.old.resize(screen.cols(), screen.rows());
            self.model.resize(screen.cols(), screen.rows());
        }
        self.model.render(screen);
        host.present(screen);
        // One event per poll also bounds arbitrarily long queued input.
        let event = queued
            .pop_front()
            .map(|e| e.ereignis)
            .or_else(|| host.next_event(false))?;
        match event {
            Ereignis::Taste(key, shift) => {
                let shift = if !self.buttons.is_empty() && key.chars().count() == 1 {
                    shift | umschalt::ALT
                } else {
                    shift
                };
                self.model.handle_key(&key, shift);
            }
            Ereignis::Maus(event) => {
                let now = if self.model.mouse_needs_time(event) {
                    host.jetzt_ms()
                } else {
                    0
                };
                self.model.handle_mouse_at(event, now);
            }
            Ereignis::Groesse { cols, rows } => {
                screen.resize(cols, rows);
                self.old.resize(cols, rows);
                self.model.resize(cols, rows);
            }
            event @ (Ereignis::Abbruch | Ereignis::Ende | Ereignis::Signal(_)) => {
                queued.push_front(event.into());
                return None;
            }
        }
        while let Some(event) = self.model.next_event() {
            if event.name != "CLICK" {
                continue;
            }
            let value = if self.buttons.is_empty() {
                match event.object {
                    3 => DialogValue::Text(self.model.string((2, None), "TEXT")),
                    4 => DialogValue::Text(String::new()),
                    _ => continue,
                }
            } else {
                let Some(code) = event
                    .object
                    .checked_sub(2)
                    .and_then(|i| self.buttons.get(i as usize))
                else {
                    continue;
                };
                DialogValue::Number(*code)
            };
            *screen = self.old.clone();
            return Some(value);
        }
        None
    }
    fn run(
        &mut self,
        screen: &mut TextScreen,
        host: &mut dyn Host,
        queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
    ) -> DialogValue {
        loop {
            if let Some(value) = self.poll(screen, host, queued) {
                return value;
            }
            if queued.front().is_some_and(|e| {
                matches!(
                    e.ereignis,
                    Ereignis::Abbruch | Ereignis::Ende | Ereignis::Signal(_)
                )
            }) {
                break;
            }
            if !queued.is_empty() {
                continue;
            }
            match host.warten(None) {
                Some(e) => queued.push_back(e.into()),
                None => break,
            }
        }
        *screen = self.old.clone();
        if self.buttons.is_empty() {
            DialogValue::Text(String::new())
        } else {
            DialogValue::Number(self.default)
        }
    }
}

pub fn msgbox_dialog(
    screen: &mut TextScreen,
    host: &mut dyn Host,
    queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
    text: &str,
    kind: i32,
    title: &str,
) -> Result<i16, RuntimeError> {
    match message_dialog(screen, text, kind, title)?.run(screen, host, queued) {
        DialogValue::Number(n) => Ok(n),
        _ => unreachable!(),
    }
}
pub fn inputbox_dialog(
    screen: &mut TextScreen,
    host: &mut dyn Host,
    queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
    prompt: &str,
    title: &str,
    initial: &str,
    position: Option<(i32, i32)>,
) -> Result<String, RuntimeError> {
    match input_dialog(screen, prompt, title, initial, position)?.run(screen, host, queued) {
        DialogValue::Text(s) => Ok(s),
        _ => unreachable!(),
    }
}

/// Zeigt MSGBOX als echtes modales FormsModel-Formular.
pub fn message_dialog(
    screen: &TextScreen,
    text: &str,
    kind: i32,
    title: &str,
) -> Result<Dialog, RuntimeError> {
    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::<usize>() + 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();
    Ok(Dialog {
        model,
        old,
        buttons: buttons.iter().map(|b| b.0).collect(),
        default: buttons[default].0,
    })
}

/// Zeigt INPUTBOX$ als echtes modales FormsModel-Formular.
pub fn input_dialog(
    screen: &TextScreen,
    prompt: &str,
    title: &str,
    initial: &str,
    position: Option<(i32, i32)>,
) -> Result<Dialog, RuntimeError> {
    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();
    Ok(Dialog {
        model,
        old,
        buttons: Vec::new(),
        default: 0,
    })
}

#[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, "<OK>    "),
            (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(),
            ""
        );
    }
}
