4316 lines
163 KiB
Rust
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(),
|
|
""
|
|
);
|
|
}
|
|
}
|