From 58b1f620ea8e080ab28286a2a5b12c78171da5c0 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 5 Sep 2026 20:48:13 +0200 Subject: [PATCH] Forms-Zustand und Darstellung korrigieren und Change archivieren --- Cargo.lock | 1 + crates/tb-runtime/src/screen.rs | 52 +- crates/tb-ui/Cargo.toml | 5 + crates/tb-ui/src/forms.rs | 720 ++++++++++++++++-- crates/tb-vm/src/interp.rs | 7 + crates/tb-vm/tests/events.rs | 46 +- docs/forms-referenz.md | 43 +- .../2026-09-05-openspec-code-review.md | 21 +- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 8 +- .../review-probes.rs | 154 ++++ .../review.md | 188 +++++ .../specs/forms-steuerelemente/spec.md | 0 .../tasks.md | 20 + .../verification.md | 90 +++ .../forms-zustand-und-bedienung/tasks.md | 13 - openspec/specs/forms-steuerelemente/spec.md | 20 + tests/compat/listenauswahl.frm | 43 ++ tests/compat/listenauswahl.out | 4 + tests/compat/timer-aktivierung.frm | 26 + tests/compat/timer-aktivierung.out | 2 + 22 files changed, 1362 insertions(+), 101 deletions(-) rename openspec/changes/{forms-zustand-und-bedienung => archive/2026-09-05-forms-zustand-und-bedienung}/.openspec.yaml (100%) rename openspec/changes/{forms-zustand-und-bedienung => archive/2026-09-05-forms-zustand-und-bedienung}/design.md (100%) rename openspec/changes/{forms-zustand-und-bedienung => archive/2026-09-05-forms-zustand-und-bedienung}/proposal.md (71%) create mode 100644 openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/review-probes.rs create mode 100644 openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/review.md rename openspec/changes/{forms-zustand-und-bedienung => archive/2026-09-05-forms-zustand-und-bedienung}/specs/forms-steuerelemente/spec.md (100%) create mode 100644 openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/tasks.md create mode 100644 openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/verification.md delete mode 100644 openspec/changes/forms-zustand-und-bedienung/tasks.md create mode 100644 tests/compat/listenauswahl.frm create mode 100644 tests/compat/listenauswahl.out create mode 100644 tests/compat/timer-aktivierung.frm create mode 100644 tests/compat/timer-aktivierung.out diff --git a/Cargo.lock b/Cargo.lock index 999a414..1a08d8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,6 +640,7 @@ dependencies = [ "tb-frontend", "tb-runtime", "thiserror", + "unicode-width 0.2.0", ] [[package]] diff --git a/crates/tb-runtime/src/screen.rs b/crates/tb-runtime/src/screen.rs index 49fdb4e..63dca21 100644 --- a/crates/tb-runtime/src/screen.rs +++ b/crates/tb-runtime/src/screen.rs @@ -399,16 +399,31 @@ impl TextScreen { match ch { '\n' => self.newline(), '\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 /// Darstellungsbreite vorrücken. Ein breites Zeichen, das nicht mehr /// vollständig in die Zeile passt, wandert als Ganzes in die nächste; /// 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 — // sie bekommen eine eigene Zelle statt sich an die vorige zu hängen. // Ceiling: echte Kombination erst, wenn ein Korpusprogramm sie braucht. @@ -437,7 +452,7 @@ impl TextScreen { let b = &mut self.belegt[self.cur_row]; *b = Some(b.map_or(letzte, |c| c.max(letzte))); self.cur_col += breite; - if self.cur_col >= self.cols { + if wrap && self.cur_col >= self.cols { self.newline(); } } @@ -541,6 +556,37 @@ mod tests { 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::(), + "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] fn unicode_zeichen_belegen_eine_zelle() { let mut s = TextScreen::new(); diff --git a/crates/tb-ui/Cargo.toml b/crates/tb-ui/Cargo.toml index 48bbaee..c470a05 100644 --- a/crates/tb-ui/Cargo.toml +++ b/crates/tb-ui/Cargo.toml @@ -18,6 +18,11 @@ crossterm = { workspace = true, optional = true } signal-hook = { workspace = true, optional = true } thiserror.workspace = true log.workspace = true +unicode-width.workspace = true [dev-dependencies] anyhow.workspace = true + +[[example]] +name = "spike" +required-features = ["terminal"] diff --git a/crates/tb-ui/src/forms.rs b/crates/tb-ui/src/forms.rs index 242f74a..8539a8f 100644 --- a/crates/tb-ui/src/forms.rs +++ b/crates/tb-ui/src/forms.rs @@ -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); @@ -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 { @@ -1952,6 +2051,30 @@ impl FormsModel { lines } + fn list_lines(&self, key: ObjectKey, width: usize, height: usize) -> Vec { + 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 { 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 diff --git a/crates/tb-vm/src/interp.rs b/crates/tb-vm/src/interp.rs index 8bde32a..0f2cf75 100644 --- a/crates/tb-vm/src/interp.rs +++ b/crates/tb-vm/src/interp.rs @@ -903,6 +903,7 @@ impl Vm { /// Formular schließen, ein anderes Programm starten oder einen Fehler /// auslösen. 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() { self.tick(host); let dispatched = self.zustellen(host, Zustellpunkt::Kooperativ); @@ -928,6 +929,7 @@ impl Vm { } pub fn run(&mut self, host: &mut dyn Host) -> RunEvent { + self.forms.sync_timers(|| host.jetzt_ms()); loop { if let Some(form) = self.frames.last().and_then(|f| f.waiting_form) { if !self.forms.is_visible(form) { @@ -1381,11 +1383,13 @@ impl Vm { let value = self.pop()?; let value = self.property_value(object, property, value)?; self.forms.set_at(object, index, property, value)?; + self.forms.sync_timers(|| host.jetzt_ms()); return Ok(Flow::Normal); } } let value = self.property_value(object, property, value)?; self.forms.set_at(object, index, property, value)?; + self.forms.sync_timers(|| host.jetzt_ms()); Ok(Flow::Normal) } I::PushObject(object, has_index) => { @@ -1442,6 +1446,7 @@ impl Vm { .ok_or(RuntimeError(422))?; let value = self.property_value(object, property, value)?; self.forms.set_at(object, index, property, value)?; + self.forms.sync_timers(|| host.jetzt_ms()); Ok(Flow::Normal) } I::LoadObjectIndexedProperty(object, property) => { @@ -1585,6 +1590,7 @@ impl Vm { self.forms.object_method_at(object, index, name, args)?; } } + self.forms.sync_timers(|| host.jetzt_ms()); self.dispatch_next_form_event(true); Ok(Flow::Normal) } @@ -1602,6 +1608,7 @@ impl Vm { self.forms.ensure_loaded(object)?; self.dispatch_next_form_event(true); } + self.forms.sync_timers(|| host.jetzt_ms()); Ok(Flow::Normal) } I::Dup => { diff --git a/crates/tb-vm/tests/events.rs b/crates/tb-vm/tests/events.rs index ebf6bb3..ced34b3 100644 --- a/crates/tb-vm/tests/events.rs +++ b/crates/tb-vm/tests/events.rs @@ -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_runtime::host::{CaptureHost, Ereignis, Host, MausArt, MausEreignis}; use tb_runtime::screen::TextScreen; @@ -582,3 +582,47 @@ fn return_label_verwirft_abgebrochene_eingabe_und_ausdruck() { 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); +} diff --git a/docs/forms-referenz.md b/docs/forms-referenz.md index 18bed74..3cfcff5 100644 --- a/docs/forms-referenz.md +++ b/docs/forms-referenz.md @@ -64,6 +64,19 @@ Parent, Sorted, TabIndex, TabStop, Tag, Text, Top, Visible, Width. **ComboBox** — wie ListBox plus Change, DropDown (Ereignisse), SelLength/ 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), Drag*, GotFocus, Key*, LostFocus. Methoden: DRAG, MOVE, REFRESH, SETFOCUS. 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, 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, 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 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 | |---|---|---|---| | CommandButton, Höhe 1 | `` | `` | `` | @@ -182,7 +217,13 @@ Referenzverhalten: - **Timer-Reihenfolge:** Sind mehrere Timer am selben Zustellpunkt fällig, werden ihre Ereignisse in aufsteigender, ASCII-unabhängig 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 immer explizit (`Text1.Text`). „Default" ist nur die CommandButton- diff --git a/docs/reviews/2026-09-05-openspec-code-review.md b/docs/reviews/2026-09-05-openspec-code-review.md index 3b99070..891a0a9 100644 --- a/docs/reviews/2026-09-05-openspec-code-review.md +++ b/docs/reviews/2026-09-05-openspec-code-review.md @@ -16,6 +16,21 @@ bestehen. Die konkreten Implementierungs- und aktuellen Abnahmenachweise stehen Die Befundtexte und die Matrix unten dokumentieren weiterhin den ursprünglichen 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 - 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). -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) @@ -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). -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) @@ -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 | | 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 | | 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 | diff --git a/openspec/changes/forms-zustand-und-bedienung/.openspec.yaml b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/.openspec.yaml similarity index 100% rename from openspec/changes/forms-zustand-und-bedienung/.openspec.yaml rename to openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/.openspec.yaml diff --git a/openspec/changes/forms-zustand-und-bedienung/design.md b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/design.md similarity index 100% rename from openspec/changes/forms-zustand-und-bedienung/design.md rename to openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/design.md diff --git a/openspec/changes/forms-zustand-und-bedienung/proposal.md b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/proposal.md similarity index 71% rename from openspec/changes/forms-zustand-und-bedienung/proposal.md rename to openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/proposal.md index e273b65..a47342b 100644 --- a/openspec/changes/forms-zustand-und-bedienung/proposal.md +++ b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/proposal.md @@ -1,6 +1,6 @@ ## 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 @@ -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. +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. diff --git a/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/review-probes.rs b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/review-probes.rs new file mode 100644 index 0000000..23f9803 --- /dev/null +++ b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/review-probes.rs @@ -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")); +} diff --git a/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/review.md b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/review.md new file mode 100644 index 0000000..4d0ac95 --- /dev/null +++ b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/review.md @@ -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 D1–D3 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 | D1–D3 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 4–7 ü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 +``` diff --git a/openspec/changes/forms-zustand-und-bedienung/specs/forms-steuerelemente/spec.md b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/specs/forms-steuerelemente/spec.md similarity index 100% rename from openspec/changes/forms-zustand-und-bedienung/specs/forms-steuerelemente/spec.md rename to openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/specs/forms-steuerelemente/spec.md diff --git a/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/tasks.md b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/tasks.md new file mode 100644 index 0000000..7f5f90c --- /dev/null +++ b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/tasks.md @@ -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. diff --git a/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/verification.md b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/verification.md new file mode 100644 index 0000000..4788ecb --- /dev/null +++ b/openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/verification.md @@ -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. diff --git a/openspec/changes/forms-zustand-und-bedienung/tasks.md b/openspec/changes/forms-zustand-und-bedienung/tasks.md deleted file mode 100644 index 152fe1d..0000000 --- a/openspec/changes/forms-zustand-und-bedienung/tasks.md +++ /dev/null @@ -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. diff --git a/openspec/specs/forms-steuerelemente/spec.md b/openspec/specs/forms-steuerelemente/spec.md index 24b2223..a87eba9 100644 --- a/openspec/specs/forms-steuerelemente/spec.md +++ b/openspec/specs/forms-steuerelemente/spec.md @@ -86,6 +86,8 @@ ListBox und ComboBox SHALL `ADDITEM` und `REMOVEITEM` unterstützen und `ListIndex = -1` SHALL „keine Auswahl" bedeuten. Die ComboBox SHALL die 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 - **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 @@ -94,6 +96,14 @@ drei Stilarten (Dropdown, Simple, Dropdown List) darstellen. - **WHEN** eine ListBox ohne Auswahl gelesen wird - **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 Ein Timer SHALL bei `Enabled = -1` und `Interval > 0` sein `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 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 - **WHEN** ein Timer mit `Interval = 100` läuft und die Zeit um 250 ms vorrückt - **THEN** ist sein Ereignis zweimal gelaufen @@ -109,6 +121,14 @@ und dokumentiert sein. - **WHEN** `Timer1.Interval = 0` gesetzt wird - **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 Ein Formular SHALL eine Menüleiste mit bis zu sechs Ebenen tragen. Menüeinträge SHALL Access-Keys (`&`), Shortcuts, `Checked`, `Enabled`, diff --git a/tests/compat/listenauswahl.frm b/tests/compat/listenauswahl.frm new file mode 100644 index 0000000..56016e3 --- /dev/null +++ b/tests/compat/listenauswahl.frm @@ -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 diff --git a/tests/compat/listenauswahl.out b/tests/compat/listenauswahl.out new file mode 100644 index 0000000..fbb6c0a --- /dev/null +++ b/tests/compat/listenauswahl.out @@ -0,0 +1,4 @@ +leer=-1 +auswahl=1:b +entfernt=-1: +combo=frei0/frei1/ diff --git a/tests/compat/timer-aktivierung.frm b/tests/compat/timer-aktivierung.frm new file mode 100644 index 0000000..2f8f335 --- /dev/null +++ b/tests/compat/timer-aktivierung.frm @@ -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 diff --git a/tests/compat/timer-aktivierung.out b/tests/compat/timer-aktivierung.out new file mode 100644 index 0000000..cb03d06 --- /dev/null +++ b/tests/compat/timer-aktivierung.out @@ -0,0 +1,2 @@ +vor=0 +nach=1