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

1
Cargo.lock generated
View File

@@ -640,6 +640,7 @@ dependencies = [
"tb-frontend", "tb-frontend",
"tb-runtime", "tb-runtime",
"thiserror", "thiserror",
"unicode-width 0.2.0",
] ]
[[package]] [[package]]

View File

@@ -399,16 +399,31 @@ impl TextScreen {
match ch { match ch {
'\n' => self.newline(), '\n' => self.newline(),
'\r' => self.cur_col = 0, '\r' => self.cur_col = 0,
_ => self.zeichen_setzen(ch), _ => self.zeichen_setzen(ch, true),
} }
} }
} }
/// Eine UI-Zeile ohne Umbruch oder Scrollen bis zum Bildschirmrand zeichnen.
/// Steuerzeichen werden als Leerzeichen dargestellt; breite Zeichen passen
/// nur vollständig. Der Cursor bleibt spätestens in der letzten Spalte.
pub fn print_line(&mut self, text: &str) {
self.veraendert = true;
for ch in text.chars() {
let ch = if ch.is_control() { ' ' } else { ch };
if self.cur_col + ch.width().unwrap_or(1).max(1) > self.cols {
break;
}
self.zeichen_setzen(ch, false);
}
self.cur_col = self.cur_col.min(self.cols - 1);
}
/// Ein Zeichen an der Cursorposition setzen und den Cursor um seine /// Ein Zeichen an der Cursorposition setzen und den Cursor um seine
/// Darstellungsbreite vorrücken. Ein breites Zeichen, das nicht mehr /// Darstellungsbreite vorrücken. Ein breites Zeichen, das nicht mehr
/// vollständig in die Zeile passt, wandert als Ganzes in die nächste; /// vollständig in die Zeile passt, wandert als Ganzes in die nächste;
/// die letzte Spalte bleibt dann leer. /// die letzte Spalte bleibt dann leer.
fn zeichen_setzen(&mut self, ch: char) { fn zeichen_setzen(&mut self, ch: char, wrap: bool) {
// ponytail: Breite 0 (kombinierende Zeichen) wird wie 1 behandelt — // ponytail: Breite 0 (kombinierende Zeichen) wird wie 1 behandelt —
// sie bekommen eine eigene Zelle statt sich an die vorige zu hängen. // sie bekommen eine eigene Zelle statt sich an die vorige zu hängen.
// Ceiling: echte Kombination erst, wenn ein Korpusprogramm sie braucht. // Ceiling: echte Kombination erst, wenn ein Korpusprogramm sie braucht.
@@ -437,7 +452,7 @@ impl TextScreen {
let b = &mut self.belegt[self.cur_row]; let b = &mut self.belegt[self.cur_row];
*b = Some(b.map_or(letzte, |c| c.max(letzte))); *b = Some(b.map_or(letzte, |c| c.max(letzte)));
self.cur_col += breite; self.cur_col += breite;
if self.cur_col >= self.cols { if wrap && self.cur_col >= self.cols {
self.newline(); self.newline();
} }
} }
@@ -541,6 +556,37 @@ mod tests {
assert_eq!((s.csrlin(), s.pos()), (1, 3)); assert_eq!((s.csrlin(), s.pos()), (1, 3));
} }
#[test]
fn ui_zeile_bleibt_am_rand_ohne_umbruch_oder_scrollen() {
let mut s = TextScreen::new();
s.print("oben");
s.view_print(3, 10).unwrap();
s.set_color(2, 4);
for row in [10, 25] {
s.locate(row, 78).unwrap();
s.print_line("中xREST");
assert_eq!(s.cell(row, 78).ch, '中');
assert!(s.cell(row, 79).fortsetzung);
assert_eq!(s.cell(row, 80).ch, 'x');
assert_eq!((s.csrlin(), s.pos()), (row, 80));
assert_eq!((s.cell(row, 80).fg, s.cell(row, 80).bg), (2, 4));
}
assert_eq!(s.cell(1, 1).ch, 'o');
assert_eq!(s.cell(9, 78).ch, ' ');
assert_eq!(s.cell(24, 78).ch, ' ');
s.locate(2, 1).unwrap();
s.print_line("a\nb\rc");
assert_eq!(
(1..=5).map(|col| s.cell(2, col).ch).collect::<String>(),
"a b c"
);
s.locate(2, 80).unwrap();
s.print_line("");
assert_eq!(s.cell(2, 80).ch, ' ');
assert_eq!(s.cell(3, 1).ch, ' ');
assert_eq!((s.csrlin(), s.pos()), (2, 80));
}
#[test] #[test]
fn unicode_zeichen_belegen_eine_zelle() { fn unicode_zeichen_belegen_eine_zelle() {
let mut s = TextScreen::new(); let mut s = TextScreen::new();

View File

@@ -18,6 +18,11 @@ crossterm = { workspace = true, optional = true }
signal-hook = { workspace = true, optional = true } signal-hook = { workspace = true, optional = true }
thiserror.workspace = true thiserror.workspace = true
log.workspace = true log.workspace = true
unicode-width.workspace = true
[dev-dependencies] [dev-dependencies]
anyhow.workspace = true anyhow.workspace = true
[[example]]
name = "spike"
required-features = ["terminal"]

View File

@@ -10,6 +10,7 @@ use tb_frontend::forms::{self, FormObject, ObjectClass, PropertyDefault, Propert
use tb_runtime::errors::RuntimeError; use tb_runtime::errors::RuntimeError;
use tb_runtime::host::{taste, umschalt, Ereignis, Host, MausArt, MausEreignis}; use tb_runtime::host::{taste, umschalt, Ereignis, Host, MausArt, MausEreignis};
use tb_runtime::screen::TextScreen; use tb_runtime::screen::TextScreen;
use unicode_width::UnicodeWidthChar;
type ObjectKey = (u16, Option<i32>); type ObjectKey = (u16, Option<i32>);
@@ -195,21 +196,7 @@ impl FormsModel {
| ObjectClass::FileListBox | ObjectClass::FileListBox
) )
{ {
let selected = forms::property(obj.description.class, "LISTINDEX") return Ok(PropertyValue::String(self.list_text(key)));
.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()));
}
}
} }
if spec.name == "SELTEXT" if spec.name == "SELTEXT"
&& matches!( && matches!(
@@ -403,7 +390,7 @@ impl FormsModel {
let PropertyValue::Integer(v) = value else { let PropertyValue::Integer(v) = value else {
unreachable!() 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); return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
} }
self.instance_mut(key)?.properties[property as usize] = PropertyValue::Integer(v); self.instance_mut(key)?.properties[property as usize] = PropertyValue::Integer(v);
@@ -438,6 +425,9 @@ impl FormsModel {
self.replace_selection(key, &replacement)?; self.replace_selection(key, &replacement)?;
return Ok(()); 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)?; let obj = self.instance_mut(key)?;
obj.properties[property as usize] = value; obj.properties[property as usize] = value;
@@ -448,8 +438,14 @@ impl FormsModel {
); );
} }
} }
if matches!(spec.name, "INTERVAL" | "ENABLED") && class == ObjectClass::Timer { if spec.name == "SORTED" && self.boolean(key, "SORTED") {
self.timer_last.remove(&key); 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) { if spec.name == "TEXT" && matches!(class, ObjectClass::TextBox | ObjectClass::ComboBox) {
self.queue_named(key, "CHANGE", vec![]); self.queue_named(key, "CHANGE", vec![]);
@@ -505,6 +501,11 @@ impl FormsModel {
obj.visible = matches!(value, PropertyValue::Boolean(true)); obj.visible = matches!(value, PropertyValue::Boolean(true));
} }
obj.properties[property as usize] = value; 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; self.dirty = true;
Ok(()) Ok(())
} }
@@ -597,6 +598,7 @@ impl FormsModel {
self.active_form = self.visible_forms.last().copied(); self.active_form = self.visible_forms.last().copied();
} }
self.clear_active_control_for_form(object); self.clear_active_control_for_form(object);
self.reset_form_timers(object);
self.dirty = true; self.dirty = true;
Ok(()) Ok(())
} }
@@ -635,6 +637,7 @@ impl FormsModel {
self.active_form = self.visible_forms.last().copied(); self.active_form = self.visible_forms.last().copied();
} }
self.clear_active_control_for_form(object); self.clear_active_control_for_form(object);
self.reset_form_timers(object);
self.dirty = true; self.dirty = true;
Ok(true) Ok(true)
} }
@@ -694,6 +697,10 @@ impl FormsModel {
.remove(&(base, index)) .remove(&(base, index))
.map(|_| ()) .map(|_| ())
.ok_or(RuntimeError(340)); .ok_or(RuntimeError(340));
if removed.is_ok() {
self.reset_timer((base, Some(index)));
self.lists.remove(&(base, Some(index)));
}
self.dirty = true; self.dirty = true;
removed 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( pub fn add_item(
&mut self, &mut self,
key: ObjectKey, key: ObjectKey,
@@ -1023,46 +1051,70 @@ impl FormsModel {
if !matches!(class, ObjectClass::ListBox | ObjectClass::ComboBox) { if !matches!(class, ObjectClass::ListBox | ObjectClass::ComboBox) {
return Err(RuntimeError(421)); return Err(RuntimeError(421));
} }
let selected = self.integer(key, "LISTINDEX").unwrap_or(-1);
let sorted = self.boolean(key, "SORTED"); let sorted = self.boolean(key, "SORTED");
let items = self.lists.entry(key).or_default(); let items = self.lists.entry(key).or_default();
if sorted { let pos = if sorted {
let pos = items.partition_point(|item| item.to_uppercase() <= text.to_uppercase()); items.partition_point(|item| item.to_uppercase() <= text.to_uppercase())
items.insert(pos, text); } else {
} else if let Some(at) = at { let at = at.unwrap_or(items.len() as i32);
if at < 0 || at as usize > items.len() { if at < 0 || at as usize > items.len() {
return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
} }
items.insert(at as usize, text); at as usize
} else { };
items.push(text); 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; self.dirty = true;
Ok(()) Ok(())
} }
pub fn remove_item(&mut self, key: ObjectKey, at: i32) -> Result<(), RuntimeError> { pub fn remove_item(&mut self, key: ObjectKey, at: i32) -> Result<(), RuntimeError> {
let len = { let items = self
let items = self .lists
.lists .get_mut(&key)
.get_mut(&key) .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?;
.ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; if at < 0 || at as usize >= items.len() {
if at < 0 || at as usize >= items.len() { return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); }
} items.remove(at as usize);
items.remove(at as usize); let selected = self.integer(key, "LISTINDEX").unwrap_or(-1);
items.len() if selected >= at {
}; let property = forms::property(self.instance(key)?.description.class, "LISTINDEX")
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")
.unwrap() .unwrap()
.0 as usize; .0;
self.instance_mut(key)?.properties[id] = PropertyValue::Integer(-1); let selected = if selected == at { -1 } else { selected - 1 };
self.set_at(key.0, key.1, property, PropertyValue::Integer(selected))?;
} }
self.dirty = true; self.dirty = true;
Ok(()) 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 wildcard(pattern: &str, name: &str) -> bool {
fn matches(p: &[char], n: &[char]) -> bool { fn matches(p: &[char], n: &[char]) -> bool {
match p.split_first() { 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 { fn timer_enabled(&self, key: ObjectKey) -> bool {
self.instance(key) self.instance(key)
.is_ok_and(|obj| obj.description.class == ObjectClass::Timer) .is_ok_and(|obj| obj.description.class == ObjectClass::Timer)
@@ -1821,6 +1909,7 @@ impl FormsModel {
} }
pub fn timers(&mut self, now_ms: u64) { pub fn timers(&mut self, now_ms: u64) {
self.sync_timers(|| now_ms);
if !self.menu_path.is_empty() { if !self.menu_path.is_empty() {
return; return;
} }
@@ -1830,15 +1919,18 @@ impl FormsModel {
.filter(|key| self.timer_enabled(*key)) .filter(|key| self.timer_enabled(*key))
.collect(); .collect();
due.sort_by_key(|key| { due.sort_by_key(|key| {
self.instance(*key) (
.map(|obj| obj.description.name.to_uppercase()) self.instance(*key)
.unwrap_or_default() .map(|obj| obj.description.name.to_uppercase())
.unwrap_or_default(),
key.1.unwrap_or(0),
)
}); });
for key in due { for key in due {
let interval = self.integer(key, "INTERVAL").unwrap() as u64; let interval = self.integer(key, "INTERVAL").unwrap() as u64;
let mut count = 0; 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 { while now_ms.saturating_sub(*last) >= interval {
*last += interval; *last += interval;
count += 1; count += 1;
@@ -1901,11 +1993,19 @@ impl FormsModel {
} }
fn fit(text: &str, width: usize) -> String { fn fit(text: &str, width: usize) -> String {
let mut out: String = text.chars().take(width).collect(); let mut out = String::new();
out.extend(std::iter::repeat_n( let mut remaining = width;
' ', for ch in text.chars() {
width.saturating_sub(out.chars().count()), 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 out
} }
@@ -1921,10 +2021,9 @@ impl FormsModel {
if row == 0 || col == 0 || row > screen.rows() || col > screen.cols() { if row == 0 || col == 0 || row > screen.rows() || col > screen.cols() {
return; return;
} }
let clipped: String = text.chars().take(screen.cols() - col + 1).collect();
screen.set_color(fg, bg); screen.set_color(fg, bg);
let _ = screen.locate(row, col); 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> { fn box_lines(width: usize, height: usize, caption: &str, double: bool) -> Vec<String> {
@@ -1952,6 +2051,30 @@ impl FormsModel {
lines 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> { fn control_lines(&self, key: ObjectKey, width: usize, height: usize) -> Vec<String> {
let Ok(class) = self.instance(key).map(|obj| obj.description.class) else { let Ok(class) = self.instance(key).map(|obj| obj.description.class) else {
return vec![]; return vec![];
@@ -2086,21 +2209,7 @@ impl FormsModel {
lines lines
} }
ObjectClass::ListBox | ObjectClass::DirListBox | ObjectClass::FileListBox => { ObjectClass::ListBox | ObjectClass::DirListBox | ObjectClass::FileListBox => {
let mut lines = Self::box_lines(width, height.max(2), "", false); self.list_lines(key, width, height)
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
} }
ObjectClass::ComboBox | ObjectClass::DriveListBox => { ObjectClass::ComboBox | ObjectClass::DriveListBox => {
let text = if class == ObjectClass::DriveListBox { let text = if class == ObjectClass::DriveListBox {
@@ -2110,12 +2219,7 @@ impl FormsModel {
.cloned() .cloned()
.unwrap_or_else(|| std::path::MAIN_SEPARATOR.to_string()) .unwrap_or_else(|| std::path::MAIN_SEPARATOR.to_string())
} else { } else {
let selected = self.integer(key, "LISTINDEX").unwrap_or(-1); self.list_text(key)
self.lists
.get(&key)
.and_then(|items| items.get(selected.max(0) as usize))
.cloned()
.unwrap_or_else(|| self.string(key, "TEXT"))
}; };
let style = self.integer(key, "STYLE").unwrap_or(0); let style = self.integer(key, "STYLE").unwrap_or(0);
let first = if class == ObjectClass::ComboBox && style == 2 { let first = if class == ObjectClass::ComboBox && style == 2 {
@@ -2125,12 +2229,7 @@ impl FormsModel {
}; };
let mut lines = vec![Self::fit(&first, width)]; let mut lines = vec![Self::fit(&first, width)];
if class == ObjectClass::ComboBox && style == 1 { if class == ObjectClass::ComboBox && style == 1 {
lines.extend(Self::box_lines( lines.extend(self.list_lines(key, width, height.saturating_sub(1)));
width,
height.saturating_sub(1).max(2),
"",
false,
));
} }
lines.truncate(height); lines.truncate(height);
lines lines
@@ -2919,6 +3018,10 @@ mod tests {
let mut m = controls_model(); let mut m = controls_model();
m.add_item((7, None), "eins".into(), None).unwrap(); m.add_item((7, None), "eins".into(), None).unwrap();
m.add_item((8, None), "wahl".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())]) m.object_method(11, "PRINT", vec![PropertyValue::String("Bild".into())])
.unwrap(); .unwrap();
let snapshots = [ let snapshots = [
@@ -3060,6 +3163,7 @@ mod tests {
assert!(m.menu_is_open()); assert!(m.menu_is_open());
let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0; let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0;
m.set(12, interval, PropertyValue::Integer(100)).unwrap(); m.set(12, interval, PropertyValue::Integer(100)).unwrap();
m.sync_timers(|| 0);
m.timers(250); m.timers(250);
assert!(!m.events.iter().any(|event| event.name == "TIMER")); assert!(!m.events.iter().any(|event| event.name == "TIMER"));
m.handle_key("o", 0); m.handle_key("o", 0);
@@ -3129,6 +3233,462 @@ mod tests {
assert_ne!(m.active_control, Some((1, None))); 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] #[test]
fn text_liste_option_scroll_picture_und_dateisystem() { fn text_liste_option_scroll_picture_und_dateisystem() {
let mut m = controls_model(); let mut m = controls_model();
@@ -3470,6 +4030,7 @@ mod tests {
let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0; let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0;
m.set(12, interval, PropertyValue::Integer(100)).unwrap(); m.set(12, interval, PropertyValue::Integer(100)).unwrap();
m.sync_timers(|| 0);
m.timers(250); m.timers(250);
assert_eq!( assert_eq!(
m.events m.events
@@ -3536,6 +4097,7 @@ mod tests {
m.set(1, interval, PropertyValue::Integer(100)).unwrap(); m.set(1, interval, PropertyValue::Integer(100)).unwrap();
m.set(2, interval, PropertyValue::Integer(100)).unwrap(); m.set(2, interval, PropertyValue::Integer(100)).unwrap();
m.show(0, false).unwrap(); m.show(0, false).unwrap();
m.sync_timers(|| 0);
m.events.clear(); m.events.clear();
m.timers(250); m.timers(250);
let names: Vec<_> = m let names: Vec<_> = m

View File

@@ -903,6 +903,7 @@ impl Vm {
/// Formular schließen, ein anderes Programm starten oder einen Fehler /// Formular schließen, ein anderes Programm starten oder einen Fehler
/// auslösen. /// auslösen.
pub fn run_visible_forms(&mut self, host: &mut dyn Host) -> RunEvent { pub fn run_visible_forms(&mut self, host: &mut dyn Host) -> RunEvent {
self.forms.sync_timers(|| host.jetzt_ms());
while self.forms.has_visible_forms() { while self.forms.has_visible_forms() {
self.tick(host); self.tick(host);
let dispatched = self.zustellen(host, Zustellpunkt::Kooperativ); let dispatched = self.zustellen(host, Zustellpunkt::Kooperativ);
@@ -928,6 +929,7 @@ impl Vm {
} }
pub fn run(&mut self, host: &mut dyn Host) -> RunEvent { pub fn run(&mut self, host: &mut dyn Host) -> RunEvent {
self.forms.sync_timers(|| host.jetzt_ms());
loop { loop {
if let Some(form) = self.frames.last().and_then(|f| f.waiting_form) { if let Some(form) = self.frames.last().and_then(|f| f.waiting_form) {
if !self.forms.is_visible(form) { if !self.forms.is_visible(form) {
@@ -1381,11 +1383,13 @@ impl Vm {
let value = self.pop()?; let value = self.pop()?;
let value = self.property_value(object, property, value)?; let value = self.property_value(object, property, value)?;
self.forms.set_at(object, index, property, value)?; self.forms.set_at(object, index, property, value)?;
self.forms.sync_timers(|| host.jetzt_ms());
return Ok(Flow::Normal); return Ok(Flow::Normal);
} }
} }
let value = self.property_value(object, property, value)?; let value = self.property_value(object, property, value)?;
self.forms.set_at(object, index, property, value)?; self.forms.set_at(object, index, property, value)?;
self.forms.sync_timers(|| host.jetzt_ms());
Ok(Flow::Normal) Ok(Flow::Normal)
} }
I::PushObject(object, has_index) => { I::PushObject(object, has_index) => {
@@ -1442,6 +1446,7 @@ impl Vm {
.ok_or(RuntimeError(422))?; .ok_or(RuntimeError(422))?;
let value = self.property_value(object, property, value)?; let value = self.property_value(object, property, value)?;
self.forms.set_at(object, index, property, value)?; self.forms.set_at(object, index, property, value)?;
self.forms.sync_timers(|| host.jetzt_ms());
Ok(Flow::Normal) Ok(Flow::Normal)
} }
I::LoadObjectIndexedProperty(object, property) => { I::LoadObjectIndexedProperty(object, property) => {
@@ -1585,6 +1590,7 @@ impl Vm {
self.forms.object_method_at(object, index, name, args)?; self.forms.object_method_at(object, index, name, args)?;
} }
} }
self.forms.sync_timers(|| host.jetzt_ms());
self.dispatch_next_form_event(true); self.dispatch_next_form_event(true);
Ok(Flow::Normal) Ok(Flow::Normal)
} }
@@ -1602,6 +1608,7 @@ impl Vm {
self.forms.ensure_loaded(object)?; self.forms.ensure_loaded(object)?;
self.dispatch_next_form_event(true); self.dispatch_next_form_event(true);
} }
self.forms.sync_timers(|| host.jetzt_ms());
Ok(Flow::Normal) Ok(Flow::Normal)
} }
I::Dup => { I::Dup => {

View File

@@ -1,4 +1,4 @@
//! Regressionen zum Change ereigniszustellung-und-hostgrenzen. //! Regressionen zu Ereigniszustellung, Hostgrenzen und Forms-Zustand.
use tb_frontend::forms::{FormCatalog, ObjectClass}; use tb_frontend::forms::{FormCatalog, ObjectClass};
use tb_runtime::host::{CaptureHost, Ereignis, Host, MausArt, MausEreignis}; use tb_runtime::host::{CaptureHost, Ereignis, Host, MausArt, MausEreignis};
use tb_runtime::screen::TextScreen; use tb_runtime::screen::TextScreen;
@@ -582,3 +582,47 @@ fn return_label_verwirft_abgebrochene_eingabe_und_ausdruck() {
assert_eq!(string(&vm, "t"), "b"); assert_eq!(string(&vm, "t"), "b");
} }
} }
#[test]
fn spaete_timeraktivierung_nutzt_hostzeit_auch_ueber_control_parameter() {
for (setup, start) in [
("", "Timer1.Interval = 100"),
("", "CALL Starten(Timer1)"),
(
"Timer1.Enabled = 0\nTimer1.Interval = 100",
"Timer1.Enabled = -1",
),
] {
let mut vm = vm(&format!("DIM SHARED n%\nForm1.Show\n{setup}\nSTOP\n{start}\nSTOP\nDOEVENTS\nSTOP\nDOEVENTS\nEND\nSUB Starten(t AS CONTROL)\nt.Interval = 100\nEND SUB\nSUB Timer1_Timer()\nSHARED n%\nn% = n% + 1\nEND SUB"));
let mut host = CaptureHost::default();
stop(&mut vm, &mut host);
host.uhr_vorruecken(1000);
stop(&mut vm, &mut host);
assert_eq!(vm.forms.next_deadline(), Some(1100));
host.uhr_vorruecken(99);
stop(&mut vm, &mut host);
assert_eq!(integer(&vm, "n"), 0);
host.uhr_vorruecken(1);
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert_eq!(integer(&vm, "n"), 1);
}
}
#[test]
fn intervallaenderung_im_handler_verwirft_bereits_faellige_altintervalle() {
let mut vm = vm("DIM SHARED n%\nForm1.Show\nTimer1.Interval = 100\nSLEEP 60\nSTOP\nDOEVENTS\nSTOP\nDOEVENTS\nEND\nSUB Timer1_Timer()\nSHARED n%\nn% = n% + 1\nIF n% = 1 THEN\nSTOP\nTimer1.Interval = 200\nELSE\nTimer1.Interval = 0\nEND IF\nEND SUB");
let mut host = CaptureHost::default();
stop(&mut vm, &mut host); // Im ersten Handler bei 100 ms.
assert_eq!(host.jetzt_ms(), 100);
host.uhr_vorruecken(350);
stop(&mut vm, &mut host); // Alter Rückstau darf nach dem Handler nicht laufen.
assert_eq!(integer(&vm, "n"), 1);
assert_eq!(vm.forms.next_deadline(), Some(650));
host.uhr_vorruecken(199);
stop(&mut vm, &mut host);
assert_eq!(integer(&vm, "n"), 1);
host.uhr_vorruecken(1);
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert_eq!(integer(&vm, "n"), 2);
assert_eq!(vm.forms.next_deadline(), None);
}

View File

@@ -64,6 +64,19 @@ Parent, Sorted, TabIndex, TabStop, Tag, Text, Top, Visible, Width.
**ComboBox** — wie ListBox plus Change, DropDown (Ereignisse), SelLength/ **ComboBox** — wie ListBox plus Change, DropDown (Ereignisse), SelLength/
SelStart/SelText/Style/Text (Eigenschaften). SelStart/SelText/Style/Text (Eigenschaften).
`ListIndex = -1` hebt die Auswahl auch bei leerer Liste auf. ADDITEM vor dem
ausgewählten Eintrag verschiebt seinen Index nach rechts, REMOVEITEM davor
nach links; Entfernen des ausgewählten Eintrags setzt ihn auf 1. Das gilt
auch für sortierte Listen. `ListCount` und `List(i)` spiegeln die geänderte
Liste unmittelbar wider. Ohne Auswahl liefern ListBox und ComboBox.Style 2
leeren Text. Die editierbaren ComboBox-Stile 0 und 1 erhalten ihren separaten
Eingabetext; ihre Anzeige entspricht auch ohne Auswahl diesem Text.
Nachträgliches `Sorted = -1` sortiert den vorhandenen Inhalt ohne Beachtung
der Groß-/Kleinschreibung. Gleich sortierte Einträge behalten ihre relative
Reihenfolge; die Auswahl bleibt beim selben Eintrag, auch bei Duplikaten.
Die Simple-ComboBox zeigt unter dem Eingabefeld ihre Listeneinträge samt
Auswahlmarkierung; Inhalt wird auf den verfügbaren Listenbereich begrenzt.
**CheckBox / OptionButton** — Ereignisse: Click (Option auch DblClick), **CheckBox / OptionButton** — Ereignisse: Click (Option auch DblClick),
Drag*, GotFocus, Key*, LostFocus. Methoden: DRAG, MOVE, REFRESH, SETFOCUS. Drag*, GotFocus, Key*, LostFocus. Methoden: DRAG, MOVE, REFRESH, SETFOCUS.
Eigenschaften: BackColor, Caption, CtlName, DragMode, Enabled, ForeColor, Eigenschaften: BackColor, Caption, CtlName, DragMode, Enabled, ForeColor,
@@ -97,6 +110,21 @@ TabStop, AutoRedraw, CurrentX, CurrentY, ScaleHeight, ScaleWidth.
**Timer** — Ereignis Timer, keine Methoden; Eigenschaften: CtlName, Enabled, **Timer** — Ereignis Timer, keine Methoden; Eigenschaften: CtlName, Enabled,
Index, Interval (0 = aus … 65 535 ms), Parent, Tag. Index, Interval (0 = aus … 65 535 ms), Parent, Tag.
Die erste Frist beginnt mit der Aktivierung auf einem sichtbaren Formular
oder dem Zuweisen eines positiven Intervalls an der aktuellen Hostzeit.
Erneutes Zuweisen von Enabled = 1 an einen bereits aktiven Timer verändert
die Frist nicht. Intervalländerungen beginnen eine neue Phase und verwerfen
alte wartende Timerereignisse; Enabled = 0 und Interval = 0 verwerfen sie
ebenfalls. Verstecken/Entladen beendet die aktive Phase. Ein durchgehend
aktiver 100-ms-Timer erzeugt bei einem Fortschritt um 250 ms zwei Ereignisse.
Zeiten vor Aktivierung oder während einer inaktiven Phase werden nicht nachgeholt.
Einbetter, die das Forms-Modell direkt verändern, rufen nach der Änderung
`sync_timers(|| host.jetzt_ms())` auf. Die Closure wird ausschließlich bei
neu beginnenden aktiven Timern ausgewertet. Die VM erledigt dies nach
Property-Zuweisungen, SHOW und dem Laden von Arrayelementen sowie beim
Übernehmen eines vorbereiteten Modells; es gibt keinen globalen Zeitcache.
**Menu** — Ereignis Click; Eigenschaften Caption, Checked, CtlName, Enabled, **Menu** — Ereignis Click; Eigenschaften Caption, Checked, CtlName, Enabled,
Index, Parent, Separator, Shortcut, Tag, Visible. Index, Parent, Separator, Shortcut, Tag, Visible.
@@ -140,6 +168,13 @@ nur den Access-Key und wird nicht ausgegeben. Die Beispiele zeigen jeweils
normal · fokussiert · deaktiviert; Inhalt wird auf die verfügbare Breite normal · fokussiert · deaktiviert; Inhalt wird auf die verfügbare Breite
gekürzt und der Rest mit Leerzeichen gefüllt. gekürzt und der Rest mit Leerzeichen gefüllt.
Listenrahmen bleiben auch bei Width = 1 oder Height = 1 innerhalb des
Steuerelements; nicht passende Rahmenstücke und Zeilen entfallen.
Die Kürzung von Listen- und Combo-Inhalten zählt Bildschirmzellen: breite
Unicode-Zeichen werden nur vollständig ausgegeben, Steuerzeichen als
Leerzeichen dargestellt. Am Bildschirmrand wird die Forms-Ausgabe ohne
Umbruch oder Scrollen abgeschnitten.
| Klasse | normal | fokussiert | deaktiviert | | Klasse | normal | fokussiert | deaktiviert |
|---|---|---|---| |---|---|---|---|
| CommandButton, Höhe 1 | `<OK>` | `<OK>` | `<OK>` | | CommandButton, Höhe 1 | `<OK>` | `<OK>` | `<OK>` |
@@ -182,7 +217,13 @@ Referenzverhalten:
- **Timer-Reihenfolge:** Sind mehrere Timer am selben Zustellpunkt fällig, - **Timer-Reihenfolge:** Sind mehrere Timer am selben Zustellpunkt fällig,
werden ihre Ereignisse in aufsteigender, ASCII-unabhängig werden ihre Ereignisse in aufsteigender, ASCII-unabhängig
großgeschriebener `CtlName`-Reihenfolge zugestellt. Mehrere verstrichene großgeschriebener `CtlName`-Reihenfolge zugestellt. Mehrere verstrichene
Intervalle desselben Timers bleiben in zeitlicher Reihenfolge davor. Intervalle desselben Timers bleiben in zeitlicher Reihenfolge davor. Bei
gleichem Arraynamen folgt der aufsteigende numerische Index.
Die Korpusfälle `listenauswahl.frm` und `timer-aktivierung.frm` prüfen diese
Verträge mit festgelegten Sollausgaben: Listenauswahl/Combo-Eingabetext und
späte Timeraktivierung bei 1000 ms mit erster Zustellung bei 1100 ms.
Dies sind Vertragsregressionen, keine neu aufgenommenen VBDOS-Referenzläufe.
**Keine Default-Eigenschaften:** `Text1 = "x"` gibt es nicht — Zugriff **Keine Default-Eigenschaften:** `Text1 = "x"` gibt es nicht — Zugriff
immer explizit (`Text1.Text`). „Default" ist nur die CommandButton- immer explizit (`Text1.Text`). „Default" ist nur die CommandButton-

View File

@@ -16,6 +16,21 @@ bestehen. Die konkreten Implementierungs- und aktuellen Abnahmenachweise stehen
Die Befundtexte und die Matrix unten dokumentieren weiterhin den ursprünglichen Die Befundtexte und die Matrix unten dokumentieren weiterhin den ursprünglichen
Reviewstand; die übrigen Changes bleiben eigenständig. Reviewstand; die übrigen Changes bleiben eigenständig.
## Umsetzungsstand: Forms-Zustand und Bedienung
F12 und F13 sind im archivierten Change
[forms-zustand-und-bedienung](../../openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/proposal.md)
implementiert. Listenauswahl und Timerphasen sind durch Modell-, VM- und
Korpustests abgesichert: 207 Tests bestanden, 1 bewusst ignoriert. Details
stehen im [Implementierungsnachweis](../../openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/verification.md).
Die anschließende [Verifikation](../../openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/review.md)
hat zusätzlich zwei Vertragslücken bei nachträglicher Sortierung und der
Simple-ComboBox-Darstellung aufgedeckt; beide sind inzwischen behoben.
Auch der anschließend gefundene Überlauf bei Mindestmaßen (V3) ist behoben,
einschließlich breiter Zeichen und Bildschirmränder. Die erneute Verifikation
hat keine offenen Befunde im Change: 428 Workspace-Tests und alle vier
Review-Proben bestehen; eine Bildschirmmatrix deckt 3.360 Kombinationen ab.
## Prüfmethode und Grenzen ## Prüfmethode und Grenzen
- Alle Requirements/Szenarien gelesen und gegen Einstiegspunkte, Implementierung und vorhandene Tests abgeglichen. Die Matrix unten enthält jeden Requirement-Titel einmal. - Alle Requirements/Szenarien gelesen und gegen Einstiegspunkte, Implementierung und vorhandene Tests abgeglichen. Die Matrix unten enthält jeden Requirement-Titel einmal.
@@ -122,7 +137,7 @@ Das Setzen auf -1 liefert Fehler 5, auch wenn die Liste Einträge besitzt. Die n
Beleg: [crates/tb-ui/src/forms.rs:409](../../crates/tb-ui/src/forms.rs#L409). Vertrag: [openspec/specs/forms-steuerelemente/spec.md:72](../../openspec/specs/forms-steuerelemente/spec.md#L72). Beleg: [crates/tb-ui/src/forms.rs:409](../../crates/tb-ui/src/forms.rs#L409). Vertrag: [openspec/specs/forms-steuerelemente/spec.md:72](../../openspec/specs/forms-steuerelemente/spec.md#L72).
Umsetzung: [forms-zustand-und-bedienung](../../openspec/changes/forms-zustand-und-bedienung/proposal.md). Umsetzung: [forms-zustand-und-bedienung](../../openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/proposal.md).
### F13 — Forms-Timer holen Zeit vor ihrer Aktivierung nach (mittel) ### F13 — Forms-Timer holen Zeit vor ihrer Aktivierung nach (mittel)
@@ -130,7 +145,7 @@ Wird Interval=100 erst bei Hostzeit 1000 ms gesetzt, entstehen sofort zehn TIMER
Beleg: [crates/tb-ui/src/forms.rs:1780](../../crates/tb-ui/src/forms.rs#L1780). Vertrag: [openspec/specs/forms-steuerelemente/spec.md:87](../../openspec/specs/forms-steuerelemente/spec.md#L87). Beleg: [crates/tb-ui/src/forms.rs:1780](../../crates/tb-ui/src/forms.rs#L1780). Vertrag: [openspec/specs/forms-steuerelemente/spec.md:87](../../openspec/specs/forms-steuerelemente/spec.md#L87).
Umsetzung: [forms-zustand-und-bedienung](../../openspec/changes/forms-zustand-und-bedienung/proposal.md). Umsetzung: [forms-zustand-und-bedienung](../../openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/proposal.md).
### F14 — INPUT # verwirft Felder und ersetzt fehlende Daten still (hoch) ### F14 — INPUT # verwirft Felder und ersetzt fehlende Daten still (hoch)
@@ -234,7 +249,7 @@ Umsetzung: [spezifikationsabgleich-und-regressionsnachweise](../../openspec/chan
|---|---|---|---| |---|---|---|---|
| 1 | [isam-transaktionen-und-dateinummern](../../openspec/changes/archive/2026-09-05-isam-transaktionen-und-dateinummern/proposal.md) | F01, F02, F03 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | | 1 | [isam-transaktionen-und-dateinummern](../../openspec/changes/archive/2026-09-05-isam-transaktionen-und-dateinummern/proposal.md) | F01, F02, F03 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren |
| 2 | [ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md) | F04, F05, F06, F07, F08, F18, F20 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | | 2 | [ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md) | F04, F05, F06, F07, F08, F18, F20 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren |
| 3 | [forms-zustand-und-bedienung](../../openspec/changes/forms-zustand-und-bedienung/proposal.md) | F12, F13 | Nach Ereigniszustellung | | 3 | [forms-zustand-und-bedienung](../../openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/proposal.md) | F12, F13 | Nach Ereigniszustellung |
| 4 | [projektmodule-und-kompilat](../../openspec/changes/projektmodule-und-kompilat/proposal.md) | F09, F10, F11, F24 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | | 4 | [projektmodule-und-kompilat](../../openspec/changes/projektmodule-und-kompilat/proposal.md) | F09, F10, F11, F24 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren |
| 5 | [laufzeit-eingabe-und-systemstatus](../../openspec/changes/laufzeit-eingabe-und-systemstatus/proposal.md) | F14, F15, F16, F17, F19 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | | 5 | [laufzeit-eingabe-und-systemstatus](../../openspec/changes/laufzeit-eingabe-und-systemstatus/proposal.md) | F14, F15, F16, F17, F19 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren |
| 6 | [spezifikationsabgleich-und-regressionsnachweise](../../openspec/changes/spezifikationsabgleich-und-regressionsnachweise/proposal.md) | F21, F22, F23, F25 | Abschließende Gesamtabnahme nach den fünf Korrektur-Changes | | 6 | [spezifikationsabgleich-und-regressionsnachweise](../../openspec/changes/spezifikationsabgleich-und-regressionsnachweise/proposal.md) | F21, F22, F23, F25 | Abschließende Gesamtabnahme nach den fünf Korrektur-Changes |

View File

@@ -1,6 +1,6 @@
## Why ## Why
Listen und Timer erfüllen einfache gültige Zustandsübergänge nicht: ListIndex=-1 liefert Fehler 5; ein spät aktivierter Timer holt Zeit seit Programmstart nach. F12/F13 betreffen das Zustandsmodell unabhängig von dessen späterem Ereignisdispatch. Siehe [Review vom 05.09.2026](../../../docs/reviews/2026-09-05-openspec-code-review.md). Listen und Timer erfüllen einfache gültige Zustandsübergänge nicht: ListIndex=-1 liefert Fehler 5; ein spät aktivierter Timer holt Zeit seit Programmstart nach. F12/F13 betreffen das Zustandsmodell unabhängig von dessen späterem Ereignisdispatch. Siehe [Review vom 05.09.2026](../../../../docs/reviews/2026-09-05-openspec-code-review.md).
## What Changes ## What Changes
@@ -21,4 +21,10 @@ Keine neuen Capability-Pfade.
crates/tb-ui/src/forms.rs, Forms-Unit-/VM-Tests, Formular-Korpus und docs/forms-referenz.md. Baut auf der geordneten Ereigniszustellung auf. crates/tb-ui/src/forms.rs, Forms-Unit-/VM-Tests, Formular-Korpus und docs/forms-referenz.md. Baut auf der geordneten Ereigniszustellung auf.
Die Behebung der Darstellungsbefunde betrifft zusätzlich
crates/tb-runtime/src/screen.rs: Forms-Zeilen verwenden den vorhandenen
Zellenschreibpfad ohne Umbruch oder Scrollen am Bildschirmrand. `tb-ui`
nutzt die bereits vorhandene Workspace-Abhängigkeit `unicode-width` für
dieselbe Breitenberechnung wie die Runtime.
Nach ereigniszustellung-und-hostgrenzen. Ändert nur die Requirements zu Listen und Timer, nicht Fokus/Menü desselben Capability-Pfads. Nach ereigniszustellung-und-hostgrenzen. Ändert nur die Requirements zu Listen und Timer, nicht Fokus/Menü desselben Capability-Pfads.

View File

@@ -0,0 +1,154 @@
//! Unabhängige Review-Proben: zwei behobene Vertragslücken, Timer und Mindestmaße.
//! Ausführung über den temporären Cargo-Treiber aus review.md.
use tb_frontend::forms::{self, FormCatalog, ObjectClass};
use tb_runtime::{
host::{CaptureHost, Host},
screen::TextScreen,
};
use tb_ui::forms::{FormsModel, PropertyValue as V};
use tb_vm::interp::{RunEvent, Vm};
fn model(class: ObjectClass) -> FormsModel {
let mut c = FormCatalog::default();
c.add("Form1", ObjectClass::Form, None, false);
c.add("List1", class, Some("Form1"), false);
let mut m = FormsModel::new(c.objects, 80, 25);
m.set_initial(0, prop(ObjectClass::Form, "WIDTH"), V::Integer(40))
.unwrap();
m.set_initial(0, prop(ObjectClass::Form, "HEIGHT"), V::Integer(12))
.unwrap();
m
}
fn prop(class: ObjectClass, name: &str) -> u16 {
forms::property(class, name).unwrap().0
}
#[test]
fn sorted_nach_befuellen_ordnet_liste_und_erhaelt_auswahl() {
let mut results = vec![];
for class in [ObjectClass::ListBox, ObjectClass::ComboBox] {
let mut m = model(class);
m.add_item((1, None), "b".into(), None).unwrap();
m.add_item((1, None), "a".into(), None).unwrap();
m.set(1, prop(class, "LISTINDEX"), V::Integer(0)).unwrap();
m.set(1, prop(class, "SORTED"), V::Boolean(true)).unwrap();
m.add_item((1, None), "c".into(), None).unwrap();
let list: Vec<_> = (0..3)
.map(|i| m.get_indexed(1, prop(class, "LIST"), i).unwrap())
.collect();
println!(
"{class:?}: {list:?}; Auswahl={:?}",
m.get(1, prop(class, "LISTINDEX")).unwrap()
);
results.push((list, m.get(1, prop(class, "LISTINDEX")).unwrap()));
}
for (list, index) in results {
assert_eq!(
list,
vec![
V::String("a".into()),
V::String("b".into()),
V::String("c".into())
]
);
assert_eq!(index, V::Integer(1));
}
}
#[test]
fn combo_simple_zeigt_seine_listeneintraege() {
let class = ObjectClass::ComboBox;
let mut m = model(class);
for (name, value) in [
("STYLE", V::Integer(1)),
("WIDTH", V::Integer(15)),
("HEIGHT", V::Integer(5)),
("LEFT", V::Integer(2)),
("TOP", V::Integer(2)),
("TEXT", V::String("frei".into())),
] {
m.set_initial(1, prop(class, name), value).unwrap();
}
m.add_item((1, None), "apfel".into(), None).unwrap();
m.add_item((1, None), "birne".into(), None).unwrap();
m.show(0, false).unwrap();
let mut screen = TextScreen::new();
m.render(&mut screen);
let text = tb_runtime::snapshot::text(&screen);
println!("{text}");
assert!(
text.contains("frei"),
"Kontrolle: das Control muss sichtbar sein"
);
assert!(text.contains("apfel") && text.contains("birne"));
}
#[test]
fn timer_array_wird_beim_laden_an_aktueller_hostzeit_aktiviert() {
let mut c = FormCatalog::default();
c.add("Form1", ObjectClass::Form, None, false);
c.add("Timer1", ObjectClass::Timer, Some("Form1"), true);
let src="DIM SHARED s$\nForm1.Show\nTimer1(0).Interval=100\nSTOP\nLOAD Timer1(1)\nTimer1(0).Interval=0\nSTOP\nDOEVENTS\nEND\nSUB Timer1_Timer(Index AS INTEGER)\nSHARED s$\ns$=s$+LTRIM$(STR$(Index))\nEND SUB";
let mut vm = Vm::new(tb_vm::compile_source_with_forms("FORM1", src, &c).unwrap());
let mut h = CaptureHost::default();
assert!(matches!(vm.run(&mut h), RunEvent::Stopped { .. }));
h.uhr_setzen(1000);
assert!(matches!(vm.run(&mut h), RunEvent::Stopped { .. }));
assert_eq!(vm.forms.next_deadline(), Some(1100));
h.uhr_setzen(1100);
assert_eq!(vm.run(&mut h), RunEvent::Ended);
assert_eq!(h.jetzt_ms(), 1100);
}
#[test]
fn listen_bleiben_auch_bei_minimalmassen_im_controlrechteck() {
let mut failures = vec![];
for (class, width, height) in [
(ObjectClass::ComboBox, 15, 5),
(ObjectClass::ListBox, 6, 4),
(ObjectClass::ComboBox, 1, 5),
(ObjectClass::ListBox, 1, 4),
(ObjectClass::ListBox, 6, 1),
] {
let mut m = model(class);
for (name, value) in [
("WIDTH", width),
("HEIGHT", height),
("LEFT", 2),
("TOP", 2),
] {
m.set_initial(1, prop(class, name), V::Integer(value))
.unwrap();
}
if class == ObjectClass::ComboBox {
m.set_initial(1, prop(class, "STYLE"), V::Integer(1))
.unwrap();
}
m.add_item((1, None), "apfel".into(), None).unwrap();
m.set(1, prop(class, "VISIBLE"), V::Boolean(false)).unwrap();
m.show(0, false).unwrap();
let mut screen = TextScreen::new();
m.render(&mut screen);
let before = tb_runtime::snapshot::text(&screen);
m.set(1, prop(class, "VISIBLE"), V::Boolean(true)).unwrap();
m.render(&mut screen);
let after = tb_runtime::snapshot::text(&screen);
let mut outside = vec![];
for (row, (a, b)) in before.lines().zip(after.lines()).enumerate() {
for (col, (a, b)) in a.chars().zip(b.chars()).enumerate() {
if a != b
&& !(row >= 2
&& row < 2 + height as usize
&& col >= 2
&& col < 2 + width as usize)
{
outside.push((row + 1, col + 1, a, b));
}
}
}
if !outside.is_empty() {
failures.push(format!("{class:?} {width}x{height}: {outside:?}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}

View File

@@ -0,0 +1,188 @@
# Verifikation: forms-zustand-und-bedienung
Abnahme vom 05.09.2026, umgesetzt auf Basis von `644a862`.
Nach erfolgreicher Verifikation am selben Tag synchronisiert und archiviert. **V1, V2 und V3 sind behoben. Keine
offenen Befunde im Change.** Beide Delta-Requirements, alle acht Szenarien,
die ergänzten Abnahmetasks und D1D3 wurden erneut gegen Code und Tests geprüft.
| Dimension | Ergebnis |
| --- | --- |
| Vollständigkeit | 7/7 Tasks vollständig umgesetzt; beide Delta-Requirements mit allen acht Szenarien abgedeckt. Task 4.2 einschließlich Mindestmaßen und verwandten Zeichenpfaden erfüllt. |
| Korrektheit | 428 Workspace-Tests und alle vier Review-Proben bestanden. Keine kritischen Befunde, Warnungen oder offenen Verbesserungsvorschläge. |
| Kohärenz | D1D3 umgesetzt. Die stabile Sortierung erhält die Auswahl; Simple-ComboBox und ListBox verwenden denselben Listenrenderer. |
## V3 — behoben; historischer Befund vor der Korrektur
Die folgenden Beobachtungen gelten für den vorherigen Stand. Die Korrektur
und ihre erweiterten Nachweise stehen im nächsten Abschnitt.
### V3 (ursprünglich mittel): Listenrenderer überschreitet zulässige Mindestmaße
**Reproduktion:** Sichtbare Simple-ComboBox mit Width = 1, Height = 5,
Left = 2, Top = 2 in einem ausreichend großen Formular. Gegenüber dem
Bildschirm mit ausgeblendetem Control werden auch die Zellen in Spalte 4,
Zeilen 47 überschrieben; die einzige erlaubte Controlspalte ist Spalte 3.
Eine ListBox mit Width = 1 hat denselben horizontalen Überlauf. Bei einer
ListBox mit Width = 6, Height = 1 wird zusätzlich die vollständige Zeile
unterhalb des Controls gezeichnet. Benachbarte Inhalte können dadurch
überschrieben werden.
**Ursache:** [list_lines](../../../../crates/tb-ui/src/forms.rs#L2047) verwendet
`box_lines`, das Breite und Höhe auf mindestens 2 erhöht. Für Width < 3
kehrt der Listenrenderer ohne Begrenzung zurück; im ListBox-Pfad fehlt
auch eine abschließende Höhenbegrenzung. `write_at` begrenzt ausschließlich
auf den Bildschirm. [Width und Height](../../../../crates/tb-frontend/src/forms.rs#L202)
lassen ausdrücklich den Wert 1 zu.
**Vertrag:** Task 4.2 verlangt die Prüfung von Kürzung und Größenbegrenzung
für die Simple-ComboBox. Der neue gemeinsame Renderer übernimmt die bereits
vorhandene Mindestmaß-Lücke auch für die nun dargestellte Simple-Liste.
Die bestehenden Regressionen prüfen Breite 6 und kleine Combo-Höhen,
aber weder Breite 1 noch die ListBox-Höhe 1.
**Behebung:** Die gemeinsame Listenausgabe auf die tatsächlich angeforderte
Breite und Höhe begrenzen. Mindestbreite 1 für beide Klassen und Mindesthöhe 1
für ListBox durch Bildschirmprüfungen absichern; normale Maße beibehalten.
**Nachweis:** `listen_bleiben_auch_bei_minimalmassen_im_controlrechteck` in
[review-probes.rs](review-probes.rs) prüft den tatsächlichen Bildschirm vor
und nach Einblenden des Controls. Die Kontrollfälle ComboBox 15×5 und
ListBox 6×4 bleiben innerhalb ihrer Grenzen; die drei Mindestmaßfälle
schlugen vor der Behebung fehl und bestehen jetzt mit unveränderten Erwartungen.
## Behebung und Regressionen
- **V1:** Gemeinsame Sortierung für Laufzeit- und initiale Property-Zugriffe.
Sortierung ohne Beachtung der Groß-/Kleinschreibung; ursprüngliche Indizes
erhalten die Auswahl auch zwischen gleichen Texten. Regressionen prüfen
beide Klassen, alle ComboBox-Stile, Basis-/Arrayindex 0 und dynamische
Arrayelemente, wiederholtes Sortieren sowie anschließendes Einfügen/Entfernen.
Der Formular-Korpus prüft jetzt das Einschalten von Sorted nach dem Befüllen
mit unveränderter Sollausgabe.
- **V2:** Simple verwendet den bestehenden Listenrenderer samt Rahmen und
Auswahlmarkierung. Der Bildschirmtest prüft tatsächliche Einträge, Auswahl,
Entfernen und erhaltenen Eingabetext. V3 ergänzt die Mindestmaßabnahme.
- **V3:** `list_lines` begrenzt sämtliche Rahmenzeilen auf die angeforderte
Breite und Höhe, auch vor dem frühen Rücksprung für schmale Controls.
Alle Aufrufer (ListBox, Simple-ComboBox, DirListBox und FileListBox)
verwenden diese Korrektur. Die gemeinsame Textkürzung zählt dieselben
Zellenbreiten wie TextScreen und behandelt Steuerzeichen als Leerzeichen.
`TextScreen::print_line` verwendet den vorhandenen Zellenschreibpfad ohne
Umbruch oder Scrollen; `write_at` nutzt ihn für Forms-Ausgaben.
Das normale BASIC-PRINT behält Umbruch und Scrollen.
Die zusätzliche Regression
`listenrenderer_haelt_zellgrenzen_auch_an_bildschirmraendern_ein` vergleicht
alle Bildschirmzellen außerhalb des jeweiligen Controlrechtecks einschließlich
Farben und Unicode-Fortsetzungsmarkierung. **3.360 Kombinationen bestehen:**
ListBox, Combo-Stile 0/1/2, DirListBox, FileListBox und DriveListBox;
Basis- und dynamische Arrayelemente; Breiten 1/2/3/6/15/254 und Höhen
1/2/3/5/254; vier Positionen einschließlich rechtem/unterem Bildschirmrand
und vollständig außerhalb; leere und gefüllte Listen mit langen Texten,
breiten Unicode-Zeichen und CR/LF. Vor der Korrektur schlug dieser Test
bereits bei einer ListBox 1×1 fehl.
`ui_zeile_bleibt_am_rand_ohne_umbruch_oder_scrollen` prüft zusätzlich
vollständig passende und nicht passende breite Zeichen, Farben, CR/LF
sowie den rechten/unteren Rand und einen eigenen VIEW-PRINT-Bereich.
Bestehende Zeichenbild-, Auswahl-, Timer-, VM- und Korpustests bestehen
unverändert in ihren Erwartungen.
## Ursprüngliche Befunde — historischer Stand vor der Behebung
Die folgenden Ist-Beobachtungen dokumentieren den fehlgeschlagenen Review.
Beide Befunde sind durch die Nachweise oben geschlossen.
### V1: Nachträgliches Sorted lässt den Listeninhalt unsortiert
**Reproduktion:** In ListBox oder ComboBox zuerst `b`, dann `a` hinzufügen,
Eintrag 0 (`b`) auswählen, anschließend `Sorted = -1` setzen und `c`
hinzufügen. **Ist:** `List = [b, a, c]`, ListIndex = 0.
**Soll:** sortierte Liste `[a, b, c]` mit weiterhin ausgewähltem `b`, Index 1.
Der [Property-Setter](../../../../crates/tb-ui/src/forms.rs#L430) speichert
SORTED ohne Sortierung des vorhandenen Inhalts. [ADDITEM](../../../../crates/tb-ui/src/forms.rs#L1025)
verwendet anschließend `partition_point`, das bereits sortierte Daten
voraussetzt. Der Fehler tritt bei beiden Klassen auf.
**Vertrag:** Delta „Steuerelemente mit Listeninhalt“, Sortierung bei
Sorted = 1 und Konsistenz von List/ListIndex/Text; Task 1.1.
Die Tests setzen Sorted ausschließlich vor dem Befüllen und decken den
zulässigen Wechsel an einer gefüllten Liste nicht ab.
**Behebung:** Beim Einschalten der Sortierung vorhandene Einträge sortieren
und die Auswahl ihrem bisherigen Eintrag zuordnen; anschließend Einfügen
und Entfernen weiterhin konsistent halten. Regression für beide Klassen,
einschließlich der Auswahl, ergänzen.
### V2: ComboBox.Style 1 zeichnet keine Listeneinträge
**Reproduktion:** Sichtbare ComboBox mit Style = 1, Width = 15, Height = 5,
Text = `frei`; ADDITEM `apfel` und `birne`. **Ist:** Der Bildschirm zeigt
`frei` und einen leeren eingerahmten Listenbereich. **Soll:** Die beiden
Einträge erscheinen in diesem ausreichend großen Bereich.
Der [Renderer](../../../../crates/tb-ui/src/forms.rs#L2184) hängt für Style 1
nur `box_lines` mit leerem Inhalt an. Er liest die vorhandenen Listeneinträge
für diesen Bereich nicht. Die Probe prüft zuerst, dass das Control über den
Text `frei` tatsächlich sichtbar ist, und danach die beiden Listeneinträge.
**Vertrag:** Delta „Steuerelemente mit Listeninhalt“ verlangt die Darstellung
aller drei ComboBox-Stile; die [Forms-Referenz](../../../../docs/forms-referenz.md#L180)
zeigt für Simple einen sichtbaren Listeneintrag. Task 1.1 verlangt die Prüfung
aller Stile. Der bestehende Stiltest prüft nur Zeilenzahl und Präfix;
die neuen Auswahltests prüfen nur die oberste Zeile.
**Behebung:** Den vorhandenen Listeninhalt samt Auswahl im Simple-Bereich
darstellen und dessen Inhalt mit einem Bildschirmtest absichern. Die bereits
festgelegten Zeichenformen beibehalten.
Beide Lücken bestanden bereits vor diesem Change. Es handelt sich um
verbleibende Abweichungen im übernommenen Listen-Requirement, nicht um neu
eingeführte Regressionen. Die Korrekturen wurden nach diesem Review umgesetzt.
## Szenarioabdeckung und positive Nachweise
| Delta-Szenario | Nachweis |
| --- | --- |
| Element hinzufügen | Bestehender Sorted-Listentest und neue Auswahlmatrix: a/b, ListCount und List(i). Nachträgliches Einschalten einschließlich stabiler Auswahl durch V1-Regressionen abgesichert. |
| Keine Auswahl | ListIndex 1 bei leerer Liste und nach Entfernen der Auswahl. |
| Auswahl ausdrücklich aufheben | Modellmatrix für ListBox und ComboBox-Stile 0/1/2; Formular-Korpus. |
| Eintrag vor Auswahl einfügen | Sortierte/unsortierte Modellmatrix prüft Indexverschiebung und erhaltenen Text. |
| Timer feuert im Abstand | Zwei Ereignisse nach 250 ms aktiver Zeit, mehrere Timer und Arrayelemente, identische Wiederholung. |
| Interval 0 schaltet ab | Modelltest, Arrayindex-0-Reset und Änderung im VM-Handler. |
| Späte Aktivierung | VM: direkte Property, Control-Parameter und Enabled; Aktivierung bei 1000, still bei 1099, einmal bei 1100 ms. Zusätzliche erfolgreiche Probe für spät geladenes Timer-Arrayelement. |
| Abschalten verwirft anstehende Ereignisse | Interval/Enabled-Reset, Array-Entladen und Handlerwechsel mit altem Rückstau. |
Erneut ausgeführt:
- `cargo test --workspace`: **428 bestanden, 0 fehlgeschlagen, 1 ignoriert**.
Ignoriert bleibt der Generator für Sollausgaben; keine Golden Files geändert.
- `openspec validate forms-zustand-und-bedienung --strict`: gültig.
- `git diff --check`: erfolgreich.
- [Zusätzliche Review-Proben](review-probes.rs): **4 bestanden, 0 fehlgeschlagen**.
`cargo clippy -p tb-ui -p tb-vm -p tb-runtime --all-targets --all-features -- -D warnings`
und `cargo fmt --all -- --check` wurden nach der Behebung erfolgreich ausgeführt. Kein interaktiver Terminal-/VBDOS-Vergleich und
keine Windows-/Linux-Matrix. Die Darstellungsprobe prüft den echten Zellenpuffer
ohne Terminal; Timerprüfungen verwenden ausschließlich virtuelle Hostzeit.
## Review-Proben reproduzieren
Im Repository-Wurzelverzeichnis ausführen; erzeugt einen separaten temporären
Cargo-Treiber. Auf dem korrigierten Stand bestehen alle vier Proben.
```sh
python3 - <<'PY'
from pathlib import Path
import json, subprocess, tempfile
root = Path.cwd()
p = Path(tempfile.mkdtemp(prefix='tb-forms-review-'))
manifest = '[package]\nname="tb-forms-review"\nversion="0.0.0"\nedition="2021"\n[workspace]\n'
manifest += '[[test]]\nname="probes"\npath=' + json.dumps(str(root / 'openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/review-probes.rs')) + '\n[dependencies]\n'
for name in ['tb-ui', 'tb-vm', 'tb-runtime', 'tb-frontend']:
manifest += name + ' = { path = ' + json.dumps(str(root / 'crates' / name)) + ' }\n'
(p / 'Cargo.toml').write_text(manifest)
subprocess.run(['cargo', 'test', '--manifest-path', str(p / 'Cargo.toml'), '--offline', '--test', 'probes', '--', '--nocapture'], check=True, timeout=120)
PY
```

View File

@@ -0,0 +1,20 @@
## 1. Listen
- [x] 1.1 ListIndex=-1 korrekt zulassen und Text/List/Count bei ADDITEM/REMOVEITEM konsistent halten; verifizieren mit leeren/sortierten Listen, Einfügen vor Auswahl, Entfernen der Auswahl und allen ComboBox-Stilen.
## 2. Timer
- [x] 2.1 Aktivierungs-/Änderungszeit über den Host erfassen und deaktivierte Queueeinträge entfernen; verifizieren mit Aktivierung bei 1000 ms, erster Fälligkeit bei 1100 ms, Änderung im Handler und Interval=0.
- [x] 2.2 Fortlaufende Timer und gleichzeitige Fälligkeit erhalten; verifizieren mit exakt zwei Ereignissen bei 250 ms sowie mehreren Timer-/Arraynamen und Wiederholbarkeit.
## 3. Abnahme
- [x] 3.1 Formular-Korpus um Listenauswahl und späte Timeraktivierung ergänzen und Referenz dokumentieren; verifizieren mit cargo test -p tb-ui, cargo test -p tb-vm und cargo test -p tb-cli --test compat.
- [x] 3.2 Delta-Specs und Diffs prüfen; verifizieren mit openspec validate forms-zustand-und-bedienung --strict und git diff --check.
Abnahmenachweise: [verification.md](verification.md).
## 4. Verifikationsbefunde
- [x] 4.1 V1: Gefüllte Listen beim Einschalten von Sorted stabil sortieren und die Auswahl einschließlich gleicher Texte erhalten; beide Klassen und Arrayzugriffe prüfen.
- [x] 4.2 V2/V3: Simple-ComboBox mit dem bestehenden Listenrenderer darstellen; Einträge, Auswahl, Kürzung und Größenbegrenzung einschließlich Mindestmaßen, breiten Zeichen und Bildschirmrändern am Bildschirm prüfen. Alle Aufrufer des Listenrenderers berücksichtigen.

View File

@@ -0,0 +1,90 @@
# Implementierungsnachweis — 05.09.2026
Change: `forms-zustand-und-bedienung`, Ausgangscommit `644a862`.
Alle sieben Tasks sind abgeschlossen; V1, V2 und V3 sind behoben. Die erneute
[Verifikation](review.md) bestätigt beide Requirements und alle acht Szenarien
ohne offene Befunde. Beide Delta-Requirements wurden am 05.09.2026 mit
der Hauptspec synchronisiert und vollständig abgeglichen; anschließend
wurde der Change unter `2026-09-05-forms-zustand-und-bedienung` archiviert.
## Umsetzung und Nachweise
| Task / Befund | Umsetzung | Ausführbarer Nachweis |
| --- | --- | --- |
| 1.1 / F12 | 1 ist bei leerer und gefüllter Liste zulässig. Einfügen/Entfernen verschiebt die Auswahl mit ihrem Eintrag; Entfernen der Auswahl hebt sie auf. Textabfrage und Combo-Anzeige verwenden denselben Auswahlzustand, editierbarer Text bleibt erhalten. | `listenauswahl_bleibt_beim_einfuegen_und_entfernen_konsistent`: ListBox und alle drei ComboBox-Stile, jeweils sortiert/unsortiert, Grenzen, Auswahl, List/Text/Count und Darstellung. Korpus `listenauswahl.frm`. |
| 2.1 / F13 | Aktivierungszeit wird über die Hostuhr erfasst. Interval-Zuweisung setzt die aktive Phase zurück; unverändertes Enabled lässt sie weiterlaufen. Reset/Deaktivierung entfernt wartende Ereignisse einschließlich Arrayindex 0. Verstecken/Entladen beendet die Phase. | `timer_neustart_verwirft_alte_fristen_und_ereignisse`; VM-Test `spaete_timeraktivierung_nutzt_hostzeit_auch_ueber_control_parameter` für direkte/dynamische Property-Zuweisung und Enabled; bei 1000 ms aktiviert, bei 1099 ms still, bei 1100 ms einmal zugestellt. |
| 2.1 | Intervalländerung im eigenen Handler verwirft bereits fällige Altintervalle. | `intervallaenderung_im_handler_verwirft_bereits_faellige_altintervalle`: erster Handler bei 100 ms, Änderung bei 450 ms, neue erste Frist bei 650 ms, dann Interval = 0. |
| 2.2 | Durchgehend aktive Timer behalten die Nachholregel. Gleichzeitige Timer werden nach Name, Arrayelemente nach numerischem Index geordnet. | `timerarrays_holen_nur_aktive_zeit_nach_und_sortieren_nach_name_und_index`: zwei Ereignisse pro Timer nach 250 ms aktiver Zeit, identische Wiederholung, gezielte Deaktivierung/Entladung und Neustart nach HIDE/SHOW. Bestehende Menüsperr-/Timer-Reihenfolgetests. |
| 3.1 | Zwei Formular-Korpusfälle mit ausdrücklich festgelegten Sollausgaben und ergänzte [Forms-Referenz](../../../../docs/forms-referenz.md). | `listenauswahl.frm`: 1, verschobene Auswahl und erhaltener Combo-Text. `timer-aktivierung.frm`: Aktivierung bei 1000 ms, Ereignis beendet SLEEP bei 1100 ms. Beide im normalen Korpus zweimal ausgeführt. |
| 3.2 | Beide vollständigen Delta-Requirements mit acht Szenarien wurden nach erfolgreicher Abnahme in die Hauptspec übernommen. | OpenSpec-Validierung und Diff-Prüfung erfolgreich. |
Das Terminalbeispiel `tb-ui/examples/spike.rs` benötigt jetzt ausdrücklich
das Feature `terminal`. Damit funktioniert auch der vorgeschriebene
Einzelaufruf `cargo test -p tb-ui` ohne die Feature-Vereinigung des
Workspace. `tb-ui` nutzt für die Zellenbreite jetzt dieselbe bereits im
Workspace vorhandene Bibliothek `unicode-width` wie `tb-runtime`; kein
zusätzliches Bibliothekspaket wurde eingeführt.
## Behebung der Verifikationsbefunde
V1, V2 und V3 sind geschlossen. Die gemeinsame stabile Sortierung erhält die
Auswahl über ursprüngliche Eintragsindizes, auch bei Duplikaten. Laufzeit-
und initiale Property-Zugriffe verwenden denselben Pfad. Die Simple-ComboBox
nutzt den Listenrenderer der ListBox einschließlich Auswahlmarkierung und
Begrenzung auf den verfügbaren Raum, einschließlich Mindestbreite und
Mindesthöhe 1. Der gemeinsame Listenrenderer begrenzt Rahmen vor dem frühen
Rücksprung für schmale Controls. `fit` berücksichtigt die Zellenbreite und
ersetzt Steuerzeichen; `TextScreen::print_line` verhindert Umbruch und
Scrollen bei Forms-Ausgaben am Bildschirmrand. BASIC-PRINT behält seinen
bisherigen Zeichenpfad mit Umbruch und Scrollen.
Die neuen Modell-/Bildschirmtests heißen
`sorted_wechsel_erhaelt_auch_die_auswahl_zwischen_duplikaten` und
`simple_combo_zeichnet_listeninhalt_auswahl_und_begrenzung`. Die drei
ursprünglichen Review-Proben bestehen unverändert in ihren Erwartungen.
Der Korpus prüft nachträgliches Sorted bei unveränderter Sollausgabe.
`listenrenderer_haelt_zellgrenzen_auch_an_bildschirmraendern_ein` prüft
3.360 Kombinationen: ListBox, alle drei Combo-Stile, DirListBox, FileListBox,
DriveListBox; Basis- und dynamische Arrayelemente; Breiten 1/2/3/6/15/254,
Höhen 1/2/3/5/254; normale, randnahe und außerhalb liegende Positionen;
leere und gefüllte Listen mit breiten Zeichen und CR/LF. Alle Zellen
außerhalb des Controlrechtecks werden einschließlich Farben und
Fortsetzungsmarkierung mit dem Ausgangsbild verglichen.
Der Test schlug vor der Korrektur fehl und besteht danach.
`ui_zeile_bleibt_am_rand_ohne_umbruch_oder_scrollen` sichert zusätzlich
den rechten/unteren Bildschirmrand und einen eigenen VIEW-PRINT-Bereich ab.
## Ausgeführte Prüfungen
Nach Behebung erneut ausgeführt:
- `cargo test --workspace`: **428 bestanden, 0 fehlgeschlagen, 1 ignoriert**.
- Separater Treiber für `review-probes.rs`: **4 bestanden, 0 fehlgeschlagen**;
die Erwartungen der ursprünglichen Mindestmaßprobe wurden nicht abgeschwächt.
- `cargo clippy -p tb-ui -p tb-vm -p tb-runtime --all-targets --all-features -- -D warnings`: erfolgreich.
- Format-, OpenSpec- und Diff-Prüfung: erfolgreich.
Die vorherige Implementierungsabnahme umfasste zusätzlich folgende Einzelaufrufe:
- `cargo test -p tb-ui`: 42 bestanden.
- `cargo test -p tb-vm`: 149 bestanden, darunter 27 Ereignisregressionen.
- Abschließend beide Pakete zusammen: `cargo test -p tb-ui -p tb-vm`,
191 bestanden, 0 fehlgeschlagen.
- `cargo test -p tb-cli --test compat`: 16 bestanden, 1 bewusst ignoriert
(`erzeuge_sollausgaben`). Keine bestehenden Golden Files verändert oder
neu aufgezeichnet.
- `cargo clippy -p tb-ui -p tb-vm --all-targets --all-features -- -D warnings`:
erfolgreich, ohne Warnungen.
- `cargo fmt --all -- --check`: erfolgreich.
- `openspec validate forms-zustand-und-bedienung --strict`: erfolgreich.
- `git diff --check`: erfolgreich.
## Grenzen
Die neuen Nachweise prüfen den vereinbarten Vertrag mit virtueller Hostzeit.
Kein neuer interaktiver VBDOS-/Terminalvergleich und keine Windows-/Linux-
Ausführung. Bestehende Zeichenbilder bleiben unverändert; ihr Combo-Test
wählt den darzustellenden Eintrag nun ausdrücklich aus. Einbetter mit
direktem Forms-Modellzugriff müssen neue Timerphasen durch `sync_timers`
mit ihrer Hostzeit beginnen lassen; die VM übernimmt das selbst.

View File

@@ -1,13 +0,0 @@
## 1. Listen
- [ ] 1.1 ListIndex=-1 korrekt zulassen und Text/List/Count bei ADDITEM/REMOVEITEM konsistent halten; verifizieren mit leeren/sortierten Listen, Einfügen vor Auswahl, Entfernen der Auswahl und allen ComboBox-Stilen.
## 2. Timer
- [ ] 2.1 Aktivierungs-/Änderungszeit über den Host erfassen und deaktivierte Queueeinträge entfernen; verifizieren mit Aktivierung bei 1000 ms, erster Fälligkeit bei 1100 ms, Änderung im Handler und Interval=0.
- [ ] 2.2 Fortlaufende Timer und gleichzeitige Fälligkeit erhalten; verifizieren mit exakt zwei Ereignissen bei 250 ms sowie mehreren Timer-/Arraynamen und Wiederholbarkeit.
## 3. Abnahme
- [ ] 3.1 Formular-Korpus um Listenauswahl und späte Timeraktivierung ergänzen und Referenz dokumentieren; verifizieren mit cargo test -p tb-ui, cargo test -p tb-vm und cargo test -p tb-cli --test compat.
- [ ] 3.2 Delta-Specs und Diffs prüfen; verifizieren mit openspec validate forms-zustand-und-bedienung --strict und git diff --check.

View File

@@ -86,6 +86,8 @@ ListBox und ComboBox SHALL `ADDITEM` und `REMOVEITEM` unterstützen und
`ListIndex = -1` SHALL „keine Auswahl" bedeuten. Die ComboBox SHALL die `ListIndex = -1` SHALL „keine Auswahl" bedeuten. Die ComboBox SHALL die
drei Stilarten (Dropdown, Simple, Dropdown List) darstellen. drei Stilarten (Dropdown, Simple, Dropdown List) darstellen.
Eine Zuweisung von ListIndex = -1 SHALL in leerer wie gefüllter Liste zulässig sein. Einfügen vor der Auswahl SHALL ihren Index verschieben, Entfernen der Auswahl SHALL sie aufheben. Eine ListBox ohne Auswahl SHALL Text als leeren String liefern; editierbare ComboBox-Stile SHALL ihren unabhängigen Eingabetext erhalten.
#### Scenario: Element hinzufügen #### Scenario: Element hinzufügen
- **WHEN** `List1.ADDITEM "b"` und `List1.ADDITEM "a"` bei `Sorted = -1` ausgeführt werden - **WHEN** `List1.ADDITEM "b"` und `List1.ADDITEM "a"` bei `Sorted = -1` ausgeführt werden
- **THEN** liefert `List1.List(0)` den Wert `a` und `List1.ListCount` den Wert 2 - **THEN** liefert `List1.List(0)` den Wert `a` und `List1.ListCount` den Wert 2
@@ -94,6 +96,14 @@ drei Stilarten (Dropdown, Simple, Dropdown List) darstellen.
- **WHEN** eine ListBox ohne Auswahl gelesen wird - **WHEN** eine ListBox ohne Auswahl gelesen wird
- **THEN** liefert `ListIndex` den Wert 1 - **THEN** liefert `ListIndex` den Wert 1
#### Scenario: Auswahl ausdrücklich aufheben
- **WHEN** nach ADDITEM die Eigenschaft ListIndex auf -1 gesetzt wird
- **THEN** tritt kein Fehler auf und ListIndex ist -1
#### Scenario: Eintrag vor Auswahl einfügen
- **WHEN** vor einem ausgewählten Eintrag ein Element eingefügt wird
- **THEN** bleibt derselbe Eintrag ausgewählt und sein Index steigt um 1
### Requirement: Timer-Steuerelement ### Requirement: Timer-Steuerelement
Ein Timer SHALL bei `Enabled = -1` und `Interval > 0` sein Ein Timer SHALL bei `Enabled = -1` und `Interval > 0` sein
`Timer`-Ereignis im eingestellten Abstand auslösen, gestützt auf die `Timer`-Ereignis im eingestellten Abstand auslösen, gestützt auf die
@@ -101,6 +111,8 @@ Zeitquelle der Ereignissteuerung. `Interval = 0` SHALL ihn abschalten.
Sind mehrere Timer gleichzeitig fällig, SHALL die Reihenfolge festgelegt Sind mehrere Timer gleichzeitig fällig, SHALL die Reihenfolge festgelegt
und dokumentiert sein. und dokumentiert sein.
Die erste Frist SHALL ab dem Einschalten beziehungsweise neu gesetzten Intervall zählen. Zeit vor der Aktivierung MUST NOT nachgeholt werden. Deaktivierung oder Interval = 0 SHALL noch anstehende Timerereignisse verwerfen.
#### Scenario: Timer feuert im Abstand #### Scenario: Timer feuert im Abstand
- **WHEN** ein Timer mit `Interval = 100` läuft und die Zeit um 250 ms vorrückt - **WHEN** ein Timer mit `Interval = 100` läuft und die Zeit um 250 ms vorrückt
- **THEN** ist sein Ereignis zweimal gelaufen - **THEN** ist sein Ereignis zweimal gelaufen
@@ -109,6 +121,14 @@ und dokumentiert sein.
- **WHEN** `Timer1.Interval = 0` gesetzt wird - **WHEN** `Timer1.Interval = 0` gesetzt wird
- **THEN** läuft kein weiteres Ereignis - **THEN** läuft kein weiteres Ereignis
#### Scenario: Späte Aktivierung
- **WHEN** bei Hostzeit 1000 ms ein zuvor inaktiver Timer mit Interval 100 eingeschaltet wird
- **THEN** läuft bis 1099 ms kein Timerereignis und bei 1100 ms genau eines
#### Scenario: Abschalten verwirft anstehende Ereignisse
- **WHEN** Timerereignisse anstehen und vor ihrer Zustellung Interval auf 0 gesetzt wird
- **THEN** werden sie nicht mehr zugestellt
### Requirement: Menüsystem ### Requirement: Menüsystem
Ein Formular SHALL eine Menüleiste mit bis zu sechs Ebenen tragen. Ein Formular SHALL eine Menüleiste mit bis zu sechs Ebenen tragen.
Menüeinträge SHALL Access-Keys (`&`), Shortcuts, `Checked`, `Enabled`, Menüeinträge SHALL Access-Keys (`&`), Shortcuts, `Checked`, `Enabled`,

View File

@@ -0,0 +1,43 @@
VERSION 1.00
Begin Form Form1
Begin ListBox List1
Sorted = -1
End
Begin ComboBox Combo0
Style = 0
Text = "frei0"
End
Begin ComboBox Combo1
Style = 1
Text = "frei1"
End
Begin ComboBox Combo2
Style = 2
End
End
' tb-screen: 80x25
' Vertragsnachweis: Auswahlverschiebung und unabhängiger Combo-Eingabetext.
Form1.Hide
CLS
List1.ListIndex = -1
PRINT "leer="; LTRIM$(STR$(List1.ListIndex))
List1.Sorted = 0
List1.ADDITEM "b"
List1.ListIndex = 0
List1.ADDITEM "a"
List1.Sorted = -1
PRINT "auswahl="; LTRIM$(STR$(List1.ListIndex)); ":"; List1.Text
List1.REMOVEITEM 1
PRINT "entfernt="; LTRIM$(STR$(List1.ListIndex)); ":"; List1.Text
Combo0.ADDITEM "wahl"
Combo1.ADDITEM "wahl"
Combo2.ADDITEM "wahl"
Combo0.ListIndex = 0
Combo1.ListIndex = 0
Combo2.ListIndex = 0
Combo0.ListIndex = -1
Combo1.ListIndex = -1
Combo2.ListIndex = -1
PRINT "combo="; Combo0.Text; "/"; Combo1.Text; "/"; Combo2.Text
END

View File

@@ -0,0 +1,4 @@
leer=-1
auswahl=1:b
entfernt=-1:
combo=frei0/frei1/

View File

@@ -0,0 +1,26 @@
VERSION 1.00
Begin Form Form1
Begin Timer Timer1
Interval = 0
End
End
' tb-screen: 80x25
' tb-events: time:0ms
' Hostfristen: Aktivierung 1000, erstes Ereignis beendet SLEEP bei 1100 ms.
DIM SHARED n%
SLEEP 1
Timer1.Interval = 100
vor% = n%
SLEEP 1
nach% = n%
Timer1.Interval = 0
Form1.Hide
CLS
PRINT "vor="; LTRIM$(STR$(vor%))
PRINT "nach="; LTRIM$(STR$(nach%))
END
SUB Timer1_Timer()
SHARED n%
n% = n% + 1
END SUB

View File

@@ -0,0 +1,2 @@
vor=0
nach=1