Forms-Zustand und Darstellung korrigieren und Change archivieren

This commit is contained in:
2026-09-05 20:48:13 +02:00
parent 644a86212f
commit 58b1f620ea
22 changed files with 1362 additions and 101 deletions

View File

@@ -10,6 +10,7 @@ use tb_frontend::forms::{self, FormObject, ObjectClass, PropertyDefault, Propert
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>);
@@ -195,21 +196,7 @@ impl FormsModel {
| ObjectClass::FileListBox
)
{
let selected = forms::property(obj.description.class, "LISTINDEX")
.and_then(|(id, _)| match obj.properties.get(id as usize) {
Some(PropertyValue::Integer(v)) => Some(*v),
_ => None,
})
.unwrap_or(-1);
if selected >= 0 {
if let Some(text) = self
.lists
.get(&key)
.and_then(|items| items.get(selected as usize))
{
return Ok(PropertyValue::String(text.clone()));
}
}
return Ok(PropertyValue::String(self.list_text(key)));
}
if spec.name == "SELTEXT"
&& matches!(
@@ -403,7 +390,7 @@ impl FormsModel {
let PropertyValue::Integer(v) = value else {
unreachable!()
};
if v < -1 || v as usize >= self.lists.get(&key).map_or(0, Vec::len) {
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);
@@ -438,6 +425,9 @@ impl FormsModel {
self.replace_selection(key, &replacement)?;
return Ok(());
}
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;
@@ -448,8 +438,14 @@ impl FormsModel {
);
}
}
if matches!(spec.name, "INTERVAL" | "ENABLED") && class == ObjectClass::Timer {
self.timer_last.remove(&key);
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![]);
@@ -505,6 +501,11 @@ impl FormsModel {
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(())
}
@@ -597,6 +598,7 @@ impl FormsModel {
self.active_form = self.visible_forms.last().copied();
}
self.clear_active_control_for_form(object);
self.reset_form_timers(object);
self.dirty = true;
Ok(())
}
@@ -635,6 +637,7 @@ impl FormsModel {
self.active_form = self.visible_forms.last().copied();
}
self.clear_active_control_for_form(object);
self.reset_form_timers(object);
self.dirty = true;
Ok(true)
}
@@ -694,6 +697,10 @@ impl FormsModel {
.remove(&(base, index))
.map(|_| ())
.ok_or(RuntimeError(340));
if removed.is_ok() {
self.reset_timer((base, Some(index)));
self.lists.remove(&(base, Some(index)));
}
self.dirty = true;
removed
}
@@ -1013,6 +1020,27 @@ impl FormsModel {
}
}
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,
@@ -1023,46 +1051,70 @@ impl FormsModel {
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();
if sorted {
let pos = items.partition_point(|item| item.to_uppercase() <= text.to_uppercase());
items.insert(pos, text);
} else if let Some(at) = at {
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);
}
items.insert(at as usize, text);
} else {
items.push(text);
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 len = {
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);
items.len()
};
let list_index = self.integer(key, "LISTINDEX").unwrap_or(-1);
if list_index >= len as i32 {
let id = forms::property(self.instance(key)?.description.class, "LISTINDEX")
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 as usize;
self.instance_mut(key)?.properties[id] = PropertyValue::Integer(-1);
.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() {
@@ -1777,6 +1829,42 @@ impl FormsModel {
}
}
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)
@@ -1821,6 +1909,7 @@ impl FormsModel {
}
pub fn timers(&mut self, now_ms: u64) {
self.sync_timers(|| now_ms);
if !self.menu_path.is_empty() {
return;
}
@@ -1830,15 +1919,18 @@ impl FormsModel {
.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()
(
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.entry(key).or_insert(0);
let last = self.timer_last.get_mut(&key).unwrap();
while now_ms.saturating_sub(*last) >= interval {
*last += interval;
count += 1;
@@ -1901,11 +1993,19 @@ impl FormsModel {
}
fn fit(text: &str, width: usize) -> String {
let mut out: String = text.chars().take(width).collect();
out.extend(std::iter::repeat_n(
' ',
width.saturating_sub(out.chars().count()),
));
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
}
@@ -1921,10 +2021,9 @@ impl FormsModel {
if row == 0 || col == 0 || row > screen.rows() || col > screen.cols() {
return;
}
let clipped: String = text.chars().take(screen.cols() - col + 1).collect();
screen.set_color(fg, bg);
let _ = screen.locate(row, col);
screen.print(&clipped);
screen.print_line(text);
}
fn box_lines(width: usize, height: usize, caption: &str, double: bool) -> Vec<String> {
@@ -1952,6 +2051,30 @@ impl FormsModel {
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![];
@@ -2086,21 +2209,7 @@ impl FormsModel {
lines
}
ObjectClass::ListBox | ObjectClass::DirListBox | ObjectClass::FileListBox => {
let mut lines = Self::box_lines(width, height.max(2), "", false);
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
self.list_lines(key, width, height)
}
ObjectClass::ComboBox | ObjectClass::DriveListBox => {
let text = if class == ObjectClass::DriveListBox {
@@ -2110,12 +2219,7 @@ impl FormsModel {
.cloned()
.unwrap_or_else(|| std::path::MAIN_SEPARATOR.to_string())
} else {
let selected = self.integer(key, "LISTINDEX").unwrap_or(-1);
self.lists
.get(&key)
.and_then(|items| items.get(selected.max(0) as usize))
.cloned()
.unwrap_or_else(|| self.string(key, "TEXT"))
self.list_text(key)
};
let style = self.integer(key, "STYLE").unwrap_or(0);
let first = if class == ObjectClass::ComboBox && style == 2 {
@@ -2125,12 +2229,7 @@ impl FormsModel {
};
let mut lines = vec![Self::fit(&first, width)];
if class == ObjectClass::ComboBox && style == 1 {
lines.extend(Self::box_lines(
width,
height.saturating_sub(1).max(2),
"",
false,
));
lines.extend(self.list_lines(key, width, height.saturating_sub(1)));
}
lines.truncate(height);
lines
@@ -2919,6 +3018,10 @@ mod tests {
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 = [
@@ -3060,6 +3163,7 @@ mod tests {
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);
@@ -3129,6 +3233,462 @@ mod tests {
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!(m.events.is_empty());
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();
@@ -3470,6 +4030,7 @@ mod tests {
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
@@ -3536,6 +4097,7 @@ mod tests {
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