Files
TerminalBasic/crates/tb-ui/src/forms.rs

4316 lines
163 KiB
Rust

//! 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 {
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
}
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)
}
fn dialog_event(
model: &mut FormsModel,
screen: &mut TextScreen,
host: &mut dyn Host,
queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
plain_access_key: bool,
) -> Option<FormEvent> {
loop {
model.render(screen);
host.present(screen);
let event = queued
.pop_front()
.map(|e| e.ereignis)
.or_else(|| host.next_event(true))?;
match event {
Ereignis::Taste(key, shift) => {
let shift = if plain_access_key && key.chars().count() == 1 {
shift | umschalt::ALT
} else {
shift
};
model.handle_key(&key, shift);
}
Ereignis::Maus(event) => {
let now = if model.mouse_needs_time(event) {
host.jetzt_ms()
} else {
0
};
model.handle_mouse_at(event, now);
}
Ereignis::Groesse { cols, rows } => {
screen.resize(cols, rows);
model.resize(cols, rows);
}
event @ (Ereignis::Abbruch | Ereignis::Ende | Ereignis::Signal(_)) => {
queued.push_front(event.into());
return None;
}
}
while let Some(event) = model.next_event() {
if event.name == "CLICK" {
return Some(event);
}
}
}
}
/// Zeigt MSGBOX als echtes modales FormsModel-Formular.
pub fn msgbox_dialog(
screen: &mut TextScreen,
host: &mut dyn Host,
queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
text: &str,
kind: i32,
title: &str,
) -> Result<i16, 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();
let chosen = loop {
let Some(event) = dialog_event(&mut model, screen, host, queued, true) else {
break buttons[default].0;
};
if let Some(index) = event.object.checked_sub(2).map(usize::from) {
if let Some((code, _)) = buttons.get(index) {
break *code;
}
}
};
*screen = old;
Ok(chosen)
}
/// Zeigt INPUTBOX$ als echtes modales FormsModel-Formular.
pub fn inputbox_dialog(
screen: &mut TextScreen,
host: &mut dyn Host,
queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
prompt: &str,
title: &str,
initial: &str,
position: Option<(i32, i32)>,
) -> Result<String, 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();
let accepted = loop {
let Some(event) = dialog_event(&mut model, screen, host, queued, false) else {
break false;
};
match event.object {
3 => break true,
4 => break false,
_ => {}
}
};
let text = if accepted {
model.string((2, None), "TEXT")
} else {
String::new()
};
*screen = old;
Ok(text)
}
#[cfg(test)]
mod tests {
use super::*;
use tb_runtime::host::CaptureHost;
fn model() -> FormsModel {
let mut c = forms::FormCatalog::default();
c.add("Form1", ObjectClass::Form, None, false);
c.add("Check1", ObjectClass::CheckBox, Some("Form1"), false);
c.add("Command1", ObjectClass::CommandButton, Some("Form1"), true);
c.add("SCREEN", ObjectClass::Screen, None, false);
FormsModel::new(c.objects, 80, 25)
}
#[test]
fn combobox_dropdown_closes_on_focus_change_and_hide() {
let mut catalog = forms::FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
catalog.add("Combo", ObjectClass::ComboBox, Some("Form1"), false);
catalog.add("Text", ObjectClass::TextBox, Some("Form1"), false);
let mut m = FormsModel::new(catalog.objects, 80, 25);
m.show(0, false).unwrap();
for hide in [false, true] {
m.focus((1, None)).unwrap();
assert!(m.handle_key(&taste::sonder(80), umschalt::ALT));
assert_eq!(m.dropdown, Some((1, None)));
if hide {
m.hide(0).unwrap();
} else {
m.focus((2, None)).unwrap();
}
assert_eq!(m.dropdown, None);
}
}
#[test]
fn indexed_containers_preserve_parent_geometry_and_option_groups() {
let form = crate::frm::read_text(
"parent.frm",
r#"VERSION 1.00
Begin Form Form1
Width = 80
Height = 25
Begin Frame Group
Index = 0
Left = 2
Begin OptionButton Choice
Index = 0
End
End
Begin Frame Group
Index = 1
Width = 25
Height = 12
Left = 30
Top = 4
Begin OptionButton Choice
Index = 1
Left = 2
Top = 2
End
Begin OptionButton Other
Caption = "Second"
Width = 12
Left = 2
Top = 4
End
End
End
"#,
)
.unwrap();
let catalog = form.catalog();
let id = |name| catalog.find(name).unwrap().0;
let (root, group, choice, other) = (id("Form1"), id("Group"), id("Choice"), id("Other"));
let mut m = FormsModel::new(catalog.objects, 80, 25);
form.apply(&mut m).unwrap();
m.show(root, false).unwrap();
assert_eq!(m.parent_key((choice, Some(1))), Some((group, Some(1))));
assert_eq!(m.root_form((choice, Some(1))), Some(root));
assert_eq!(
m.rect((choice, Some(1))).map(|(x, y, _, _)| (x, y)),
Some((33, 7))
);
assert_eq!(m.hit_test(root, 7, 33), (choice, Some(1)));
m.activate((choice, None)).unwrap();
m.activate((choice, Some(1))).unwrap();
assert_eq!(m.integer((choice, None), "VALUE"), Some(-1));
assert_eq!(m.integer((choice, Some(1)), "VALUE"), Some(-1));
m.activate((other, None)).unwrap();
assert_eq!(m.integer((choice, None), "VALUE"), Some(-1));
assert_eq!(m.integer((choice, Some(1)), "VALUE"), Some(0));
assert_eq!(m.hit_test(root, 9, 33), (other, None));
let mut screen = TextScreen::new();
m.render(&mut screen);
assert!(tb_runtime::snapshot::text(&screen).contains("Second"));
}
#[test]
fn defaults_bereich_und_implizites_laden() {
let mut m = model();
let value = forms::property(ObjectClass::CheckBox, "VALUE").unwrap().0;
assert_eq!(m.get(1, value).unwrap(), PropertyValue::Integer(0));
assert!(m.objects[1].loaded);
assert_eq!(
m.set(1, value, PropertyValue::Integer(3)),
Err(RuntimeError(5))
);
}
#[test]
fn spin_zaehlt_zyklisch_und_wiederholt_nach_interval() {
let mut catalog = forms::FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
catalog.add("Spin1", ObjectClass::Spin, Some("Form1"), false);
let mut model = FormsModel::new(catalog.objects, 80, 25);
for (name, value) in [("MIN", 0), ("MAX", 2), ("VALUE", 2), ("INTERVAL", 50)] {
let property = forms::property(ObjectClass::Spin, name).unwrap().0;
model
.set_initial(1, property, PropertyValue::Integer(value))
.unwrap();
}
model.spin((1, None), 1);
assert_eq!(model.integer((1, None), "VALUE"), Some(0));
assert_eq!(
model.events.pop_back().unwrap().args,
[PropertyValue::Integer(1)]
);
model.spin_repeat = Some(((1, None), -1));
model.timers(100);
model.timers(150);
assert_eq!(model.integer((1, None), "VALUE"), Some(2));
assert_eq!(
model.events.pop_back().unwrap().args,
[PropertyValue::Integer(2)]
);
assert_eq!(model.control_lines((1, None), 1, 2), ["", ""]);
}
#[test]
fn hide_entlaedt_nicht_und_unload_ist_abbrechbar() {
let mut m = model();
m.show(0, false).unwrap();
m.hide(0).unwrap();
assert!(m.objects[0].loaded);
assert!(!m.unload_with(0, |cancel| *cancel = 1).unwrap());
assert!(m.objects[0].loaded);
assert!(m.unload_with(0, |_| {}).unwrap());
}
#[test]
fn arrays_und_designzeitfehler() {
let mut m = model();
let caption = forms::property(ObjectClass::CommandButton, "CAPTION")
.unwrap()
.0;
m.set(2, caption, PropertyValue::String("Vorlage".into()))
.unwrap();
assert_eq!(m.load_array(2, 0), Err(RuntimeError(360)));
m.load_array(2, 3).unwrap();
assert_eq!(
m.get_at(2, Some(3), caption).unwrap(),
PropertyValue::String("Vorlage".into())
);
m.set_at(2, Some(3), caption, PropertyValue::String("Klon".into()))
.unwrap();
assert_eq!(
m.get(2, caption).unwrap(),
PropertyValue::String("Vorlage".into())
);
assert_eq!(m.unload_array(2, 0), Err(RuntimeError(362)));
m.unload_array(2, 3).unwrap();
}
#[test]
fn screen_meldet_oberstes_formular_und_aktives_control() {
let mut c = forms::FormCatalog::default();
c.add("A", ObjectClass::Form, None, false);
c.add("B", ObjectClass::Form, None, false);
c.add("Text1", ObjectClass::TextBox, Some("B"), false);
c.add("SCREEN", ObjectClass::Screen, None, false);
let mut m = FormsModel::new(c.objects, 80, 25);
m.show(0, false).unwrap();
m.show(1, false).unwrap();
m.set_active_control(2, None).unwrap();
let active_form = forms::property(ObjectClass::Screen, "ACTIVEFORM")
.unwrap()
.0;
let active_control = forms::property(ObjectClass::Screen, "ACTIVECONTROL")
.unwrap()
.0;
assert_eq!(
m.get(3, active_form).unwrap(),
PropertyValue::Object(Some((1, None)))
);
assert_eq!(
m.get(3, active_control).unwrap(),
PropertyValue::Object(Some((2, None)))
);
m.hide(1).unwrap();
assert_eq!(
m.get(3, active_form).unwrap(),
PropertyValue::Object(Some((0, None)))
);
assert_eq!(
m.get(3, active_control).unwrap(),
PropertyValue::Object(None)
);
}
#[test]
fn screen_folgt_resize() {
let mut m = model();
m.resize(120, 40);
let width = forms::property(ObjectClass::Screen, "WIDTH").unwrap().0;
let height = forms::property(ObjectClass::Screen, "HEIGHT").unwrap().0;
assert_eq!(m.get(3, width).unwrap(), PropertyValue::Integer(120));
assert_eq!(m.get(3, height).unwrap(), PropertyValue::Integer(40));
}
#[test]
fn modalfehler_400_bis_403() {
let mut m = model();
m.show(0, false).unwrap();
assert_eq!(m.show(0, true), Err(RuntimeError(400)));
m.hide(0).unwrap();
m.show(0, true).unwrap();
assert_eq!(m.show(0, false), Err(RuntimeError(401)));
let mut c = forms::FormCatalog::default();
c.add("A", ObjectClass::Form, None, false);
c.add("B", ObjectClass::Form, None, false);
let mut stacked = FormsModel::new(c.objects, 80, 25);
stacked.show(0, true).unwrap();
stacked.show(1, true).unwrap();
assert_eq!(stacked.hide(0), Err(RuntimeError(402)));
let ft = forms::property(ObjectClass::Form, "FORMTYPE").unwrap().0;
let mut mdi = model();
mdi.set(0, ft, PropertyValue::Integer(1)).unwrap();
assert_eq!(mdi.show(0, true), Err(RuntimeError(403)));
}
fn controls_model() -> FormsModel {
let mut c = forms::FormCatalog::default();
c.add("Form1", ObjectClass::Form, None, false);
for (name, class) in [
("Command1", ObjectClass::CommandButton),
("Label1", ObjectClass::Label),
("Frame1", ObjectClass::Frame),
("Check1", ObjectClass::CheckBox),
("Option1", ObjectClass::OptionButton),
("Text1", ObjectClass::TextBox),
("List1", ObjectClass::ListBox),
("Combo1", ObjectClass::ComboBox),
("HScroll1", ObjectClass::HScrollBar),
("VScroll1", ObjectClass::VScrollBar),
("Picture1", ObjectClass::PictureBox),
("Timer1", ObjectClass::Timer),
("Dir1", ObjectClass::DirListBox),
("Drive1", ObjectClass::DriveListBox),
("File1", ObjectClass::FileListBox),
("mnuDatei", ObjectClass::Menu),
] {
c.add(name, class, Some("Form1"), false);
}
c.add("mnuOpen", ObjectClass::Menu, Some("mnuDatei"), false);
c.add("Spin1", ObjectClass::Spin, Some("Form1"), false);
c.add("SCREEN", ObjectClass::Screen, None, false);
let mut m = FormsModel::new(c.objects, 80, 25);
for (id, name, value) in [
(0, "WIDTH", PropertyValue::Integer(70)),
(0, "HEIGHT", PropertyValue::Integer(24)),
(1, "WIDTH", PropertyValue::Integer(8)),
(1, "HEIGHT", PropertyValue::Integer(1)),
(1, "CAPTION", PropertyValue::String("&OK".into())),
(2, "WIDTH", PropertyValue::Integer(8)),
(2, "CAPTION", PropertyValue::String("Text".into())),
(3, "WIDTH", PropertyValue::Integer(10)),
(3, "HEIGHT", PropertyValue::Integer(3)),
(3, "CAPTION", PropertyValue::String("Rahmen".into())),
(4, "WIDTH", PropertyValue::Integer(9)),
(4, "CAPTION", PropertyValue::String("Wahl".into())),
(4, "VALUE", PropertyValue::Integer(1)),
(5, "WIDTH", PropertyValue::Integer(9)),
(5, "CAPTION", PropertyValue::String("Eins".into())),
(5, "VALUE", PropertyValue::Integer(-1)),
(6, "WIDTH", PropertyValue::Integer(8)),
(6, "HEIGHT", PropertyValue::Integer(3)),
(6, "TEXT", PropertyValue::String("abc".into())),
(7, "WIDTH", PropertyValue::Integer(8)),
(7, "HEIGHT", PropertyValue::Integer(3)),
(8, "WIDTH", PropertyValue::Integer(8)),
(9, "WIDTH", PropertyValue::Integer(8)),
(10, "HEIGHT", PropertyValue::Integer(5)),
(11, "WIDTH", PropertyValue::Integer(8)),
(11, "HEIGHT", PropertyValue::Integer(3)),
(13, "WIDTH", PropertyValue::Integer(8)),
(13, "HEIGHT", PropertyValue::Integer(3)),
(14, "WIDTH", PropertyValue::Integer(8)),
(15, "WIDTH", PropertyValue::Integer(8)),
(15, "HEIGHT", PropertyValue::Integer(3)),
(16, "CAPTION", PropertyValue::String("&Datei".into())),
(17, "CAPTION", PropertyValue::String("&Open".into())),
(18, "WIDTH", PropertyValue::Integer(1)),
(18, "HEIGHT", PropertyValue::Integer(2)),
] {
let class = m.objects[id].description.class;
let property = forms::property(class, name).unwrap().0;
m.set_initial(id as u16, property, value).unwrap();
}
m
}
#[test]
fn zeichenbilder_aller_klassen_und_buttonhoehen() {
let mut m = controls_model();
m.add_item((7, None), "eins".into(), None).unwrap();
m.add_item((8, None), "wahl".into(), None).unwrap();
let selected = forms::property(ObjectClass::ComboBox, "LISTINDEX")
.unwrap()
.0;
m.set(8, selected, PropertyValue::Integer(0)).unwrap();
m.object_method(11, "PRINT", vec![PropertyValue::String("Bild".into())])
.unwrap();
let snapshots = [
(1, "<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(),
""
);
}
}