diff --git a/crates/tb-ide/src/render.rs b/crates/tb-ide/src/render.rs index 9c64b24..1ab5a4b 100644 --- a/crates/tb-ide/src/render.rs +++ b/crates/tb-ide/src/render.rs @@ -11,74 +11,9 @@ use ratatui::{ }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -/// DOS-Farben der IDE. Die ersten 16 ANSI-Einträge gehören dem Terminalprofil. -/// BASIC-Ausgabe verwendet weiterhin ihre eigene Farbabbildung in tb-ui. +/// Feste DOS-RGB-Farben, identisch für IDE und BASIC-Ausgabe. pub fn dos(n: u8) -> Color { - static COLORS: std::sync::OnceLock = std::sync::OnceLock::new(); - dos_color( - n, - *COLORS.get_or_init(|| { - let detected = crossterm::style::available_color_count(); - // Ein generisches COLORTERM (z.B. "yes") verdeckt bei crossterm TERM. - if detected < 256 && std::env::var("TERM").is_ok_and(|s| s.contains("256")) { - 256 - } else { - detected - } - }), - ) -} - -pub fn dos_color(n: u8, colors: u16) -> Color { - let n = n.min(15) as usize; - const RGB: [(u8, u8, u8); 16] = [ - (0, 0, 0), - (0, 0, 170), - (0, 170, 0), - (0, 170, 170), - (170, 0, 0), - (170, 0, 170), - (170, 85, 0), - (170, 170, 170), - (85, 85, 85), - (85, 85, 255), - (85, 255, 85), - (85, 255, 255), - (255, 85, 85), - (255, 85, 255), - (255, 255, 85), - (255, 255, 255), - ]; - if colors > 256 { - let (r, g, b) = RGB[n]; - Color::Rgb(r, g, b) - } else if colors >= 256 { - // Fester 6x6x6-Farbwürfel / Graurampe statt profilabhängiger Systemfarben. - Color::Indexed( - [ - 16, 19, 34, 37, 124, 127, 130, 248, 240, 63, 83, 87, 203, 207, 227, 231, - ][n], - ) - } else { - [ - Color::Black, - Color::Blue, - Color::Green, - Color::Cyan, - Color::Red, - Color::Magenta, - Color::Yellow, - Color::Gray, - Color::DarkGray, - Color::LightBlue, - Color::LightGreen, - Color::LightCyan, - Color::LightRed, - Color::LightMagenta, - Color::LightYellow, - Color::White, - ][n] - } + tb_ui::screen::basic_color(n.min(15)) } pub(crate) fn pair(fg: u8, bg: u8) -> Style { @@ -160,6 +95,8 @@ impl App { 5 }, ); + // Block/Paragraph ändern Stile, löschen aber keine vorhandenen Zeichen. + f.render_widget(Clear, rect); f.render_widget( Block::default() .borders(Borders::ALL) diff --git a/crates/tb-ide/tests/app.rs b/crates/tb-ide/tests/app.rs index 6ef2fdd..8ccd8c9 100644 --- a/crates/tb-ide/tests/app.rs +++ b/crates/tb-ide/tests/app.rs @@ -930,21 +930,7 @@ fn theme_rgb(color: ratatui::style::Color) -> (u8, u8, u8) { use ratatui::style::Color; match color { Color::Rgb(r, g, b) => (r, g, b), - Color::Indexed(n @ 16..=231) => { - let n = n - 16; - let channel = |v: u8| if v == 0 { 0 } else { 55 + v * 40 }; - (channel(n / 36), channel(n / 6 % 6), channel(n % 6)) - } - Color::Indexed(n @ 232..=255) => { - let v = 8 + (n - 232) * 10; - (v, v, v) - } - _ => { - let n = (0..16) - .find(|n| tb_ide::render::dos_color(*n, 16) == color) - .unwrap_or_else(|| panic!("Unbestimmte IDE-Farbe: {color:?}")); - theme_rgb(tb_ide::render::dos_color(n, u16::MAX)) - } + _ => panic!("Keine absolute RGB-Farbe: {color:?}"), } } fn theme_contrast(fg: ratatui::style::Color, bg: ratatui::style::Color) -> f64 { @@ -983,23 +969,21 @@ fn assert_theme(app: &mut App, context: &str) { #[test] fn theme_contrast_covers_rendered_states_and_rejects_bad_pairs() { - use tb_ide::render::dos_color; - for colors in [256, u16::MAX] { - for (fg, bg) in tb_ide::options::Options::default().colors { - assert!(theme_contrast(dos_color(fg, colors), dos_color(bg, colors)) >= 4.5); - } - assert!(theme_contrast(dos_color(15, colors), dos_color(7, colors)) < 4.5); - for n in 0..16 { - let fg = if matches!(n, 0 | 1 | 4 | 5 | 6 | 8 | 9) { - 15 - } else { - 0 - }; - assert!( - theme_contrast(dos_color(fg, colors), dos_color(n, colors)) >= 4.5, - "Palettenbeschriftung {n}" - ); - } + use tb_ide::render::dos; + for (fg, bg) in tb_ide::options::Options::default().colors { + assert!(theme_contrast(dos(fg), dos(bg)) >= 4.5); + } + assert!(theme_contrast(dos(15), dos(7)) < 4.5); + for n in 0..16 { + let fg = if matches!(n, 0 | 1 | 4 | 5 | 6 | 8 | 9) { + 15 + } else { + 0 + }; + assert!( + theme_contrast(dos(fg), dos(n)) >= 4.5, + "Palettenbeschriftung {n}" + ); } let t = Temp::new(); let mut app = t.app(); @@ -1086,34 +1070,23 @@ fn theme_color_commands_use_explicit_values_and_never_modify_terminal_palette() buffer::Cell, style::Color, }; - use tb_ide::render::dos_color; - assert_eq!(dos_color(1, u16::MAX), Color::Rgb(0, 0, 170)); - assert_eq!(dos_color(5, u16::MAX), Color::Rgb(170, 0, 170)); - assert_eq!(dos_color(6, u16::MAX), Color::Rgb(170, 85, 0)); - for colors in [16, 256, u16::MAX] { - for n in 0..16 { - let color = dos_color(n, colors); - if let Color::Indexed(index) = color { - assert!(index >= 16); - } - let mut cell = Cell::default(); - cell.set_symbol("X") - .set_bg(color) - .set_fg(dos_color(15, colors)); - let mut output = Vec::new(); - CrosstermBackend::new(&mut output) - .draw(std::iter::once((0, 0, &cell))) - .unwrap(); - let sequence = String::from_utf8(output).unwrap(); - assert!(sequence.contains('X')); - if colors >= 256 { - assert!(sequence.contains("48;")); - } - if colors > 256 { - assert!(sequence.contains("48;2;")); - } - assert!(!sequence.contains("\x1b]")); // Keine globale Palettenänderung. - } + use tb_ide::render::dos; + assert_eq!(dos(1), Color::Rgb(0, 0, 170)); + assert_eq!(dos(5), Color::Rgb(170, 0, 170)); + assert_eq!(dos(6), Color::Rgb(170, 85, 0)); + for n in 0..16 { + assert_eq!(dos(n), tb_ui::screen::basic_color(n)); + let mut cell = Cell::default(); + cell.set_symbol("X").set_bg(dos(n)).set_fg(dos(15)); + let mut output = Vec::new(); + CrosstermBackend::new(&mut output) + .draw(std::iter::once((0, 0, &cell))) + .unwrap(); + let sequence = String::from_utf8(output).unwrap(); + assert!(sequence.contains('X')); + assert!(sequence.contains("38;2;255;255;255")); + assert!(sequence.contains("48;2;")); + assert!(!sequence.contains("\x1b]")); // Keine globale Palettenänderung. } } @@ -1149,3 +1122,61 @@ fn theme_designer_chrome_and_handles_preserve_program_palette() { assert_theme(&mut app, "Designer-Werkzeug"); } } + +#[test] +fn theme_windows_erase_desktop_and_underlying_windows_on_every_redraw() { + use ratatui::{ + layout::Rect, + style::{Color, Modifier}, + }; + let t = Temp::new(); + let mut app = t.app(); + app.options.syntax_checking = false; + // Ein sichtbares Zeichen macht das Durchscheinen sicher erkennbar. + app.options.desktop = '@'; + type_text(&mut app, "PRINT 42"); + let mut term = Terminal::new(TestBackend::new(app.size.0, app.size.1)).unwrap(); + for selected in [false, true, false] { + if selected { + key(&mut app, K::Char('a'), M::CONTROL); + } else { + plain(&mut app, K::Right); + } + term.draw(|f| app.render(f)).unwrap(); + let b = term.backend().buffer(); + let rect = app.rect(app.windows.iter().find(|w| w.id == app.active).unwrap()); + let inner = Rect::new(rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2); + for y in inner.y..inner.bottom() { + for x in inner.x..inner.right() { + let c = &b[(x, y)]; + assert_ne!(c.symbol(), "@", "Desktop in Fenster bei {x}/{y}"); + assert_eq!(c.bg, Color::Rgb(0, 0, 170)); + if y > inner.y { + assert_eq!(c.symbol(), " ", "Restinhalt bei {x}/{y}"); + assert!(!c.modifier.contains(Modifier::REVERSED)); + } + } + } + assert_eq!(b[(inner.x, inner.y)].symbol(), "P"); + assert_eq!( + b[(inner.x, inner.y)].modifier.contains(Modifier::REVERSED), + selected + ); + } + // Beim Aktivieren eines überlappenden Fensters müssen auch fremde Texte weg. + app.execute(Command::NewModule); + plain(&mut app, K::Enter); + assert!(app.dialog.is_none()); + term.draw(|f| app.render(f)).unwrap(); + let b = term.backend().buffer(); + let rect = app.rect(app.windows.iter().find(|w| w.id == app.active).unwrap()); + for y in rect.y + 1..rect.bottom() - 1 { + for x in rect.x + 1..rect.right() - 1 { + assert_eq!( + b[(x, y)].symbol(), + " ", + "Überdeckter Fensterinhalt bei {x}/{y}" + ); + } + } +} diff --git a/crates/tb-ide/tests/execution.rs b/crates/tb-ide/tests/execution.rs index 237ac71..e92a0b6 100644 --- a/crates/tb-ide/tests/execution.rs +++ b/crates/tb-ide/tests/execution.rs @@ -530,3 +530,35 @@ fn comment_only_source_change_also_requires_revision_choice() { DialogKind::ResumeRevision )); } + +#[test] +fn platform_probe_records_each_basic_key_once_and_keeps_ide_keys_out() { + let t = Temp::new(); + let log = t.0.join("platform-events.log"); + let source = include_str!("../../../tests/platform/input.frm") + .replace("platform-events.log", &log.to_string_lossy()); + let path = t.0.join("input.frm"); + fs::write(&path, source).unwrap(); + let mut a = t.app(""); + a.load_initial_project(path).unwrap(); + a.execute(Command::Start); + tick(&mut a); + key(&mut a, K::Char('a'), M::NONE); + tick(&mut a); + a.handle(Event::Key(KeyEvent::new_with_kind( + K::Char('a'), + M::NONE, + crossterm::event::KeyEventKind::Release, + ))); + tick(&mut a); + let events = fs::read_to_string(&log).unwrap(); + assert_eq!( + events.replace('\r', ""), + "Down: 97 : 0 \nPress: 97 : 0 \nUp: 97 : 0 \n" + ); + edit_window(&mut a); + key(&mut a, K::F(1), M::NONE); + tick(&mut a); + assert_eq!(fs::read_to_string(&log).unwrap(), events); + assert!(matches!(a.active_window().unwrap().kind, WindowKind::Help)); +} diff --git a/crates/tb-ui/examples/spike.rs b/crates/tb-ui/examples/spike.rs index b6b5ff3..38196ed 100644 --- a/crates/tb-ui/examples/spike.rs +++ b/crates/tb-ui/examples/spike.rs @@ -1,15 +1,12 @@ //! Phase-0-Spike: 80×25-Bildschirmpuffer, 16-Farben-Palette, Unicode, //! Tastatur- und Mausereignisse. //! -//! Start: `cargo run -p tb-ui --example spike` — Beenden mit Esc. +//! Start: `cargo run -p tb-ui --features terminal --example spike -- --log events.txt` — Beenden mit Esc. //! Getippte Zeichen erscheinen im Eingabebereich; Sondertasten und //! Mausereignisse werden in der Statuszeile angezeigt. -use crossterm::event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, -}; -use crossterm::execute; -use std::io::stdout; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use std::io::{stdout, Write}; use std::time::Duration; use tb_ui::screen::{ScreenWidget, TextScreen}; @@ -57,8 +54,24 @@ fn status(s: &mut TextScreen, text: &str) { } fn main() -> anyhow::Result<()> { - let mut terminal = ratatui::init(); - execute!(stdout(), EnableMouseCapture)?; + let args: Vec<_> = std::env::args_os().skip(1).collect(); + anyhow::ensure!( + args.is_empty() || (args.len() == 2 && args[0] == "--log"), + "Aufruf: spike [--log neue-datei]" + ); + let mut log = if args.len() == 2 { + Some( + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&args[1])?, + ) + } else { + None + }; + let _guard = tb_ui::terminal::TerminalGuard::enter(stdout())?; + let mut terminal = ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(stdout()))?; + let mut sequence = 0u64; let (tw, th) = crossterm::terminal::size()?; let mut screen = TextScreen::with_size(tw as usize, th as usize); @@ -70,7 +83,12 @@ fn main() -> anyhow::Result<()> { if !event::poll(Duration::from_millis(100))? { continue; } - match event::read()? { + let received = event::read()?; + sequence += 1; + if let Some(log) = &mut log { + writeln!(log, "{sequence}: {received:?}")?; + } + match received { Event::Key(k) if k.kind != KeyEventKind::Release => match k.code { KeyCode::Esc => break, KeyCode::Char(c) => { @@ -105,7 +123,5 @@ fn main() -> anyhow::Result<()> { } } - execute!(stdout(), DisableMouseCapture)?; - ratatui::restore(); Ok(()) } diff --git a/crates/tb-ui/src/screen.rs b/crates/tb-ui/src/screen.rs index 8727b91..8297812 100644 --- a/crates/tb-ui/src/screen.rs +++ b/crates/tb-ui/src/screen.rs @@ -12,11 +12,28 @@ use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::widgets::Widget; -/// Abbildung der klassischen 16-Farben-Palette auf ANSI-Indexfarben. -/// (Klassisch: 1 = Blau, 4 = Rot — ANSI: 1 = Rot, 4 = Blau usw.) +/// Klassische DOS-Farbnummern als absolute RGB-Werte, ohne Terminalprofil. pub fn basic_color(n: u8) -> Color { - const MAP: [u8; 16] = [0, 4, 2, 6, 1, 5, 3, 7, 8, 12, 10, 14, 9, 13, 11, 15]; - Color::Indexed(MAP[(n & 0x0F) as usize]) + const RGB: [(u8, u8, u8); 16] = [ + (0, 0, 0), + (0, 0, 170), + (0, 170, 0), + (0, 170, 170), + (170, 0, 0), + (170, 0, 170), + (170, 85, 0), + (170, 170, 170), + (85, 85, 85), + (85, 85, 255), + (85, 255, 85), + (85, 255, 255), + (255, 85, 85), + (255, 85, 255), + (255, 255, 85), + (255, 255, 255), + ]; + let (r, g, b) = RGB[(n & 0x0F) as usize]; + Color::Rgb(r, g, b) } /// Zeichenbares Gegenstück zu [`TextScreen`]. @@ -66,9 +83,9 @@ mod tests { #[test] fn farbabbildung() { - assert_eq!(basic_color(1), Color::Indexed(4)); // klassisch Blau - assert_eq!(basic_color(4), Color::Indexed(1)); // klassisch Rot - assert_eq!(basic_color(14), Color::Indexed(11)); // Gelb - assert_eq!(basic_color(15), Color::Indexed(15)); // Weiß + assert_eq!(basic_color(1), Color::Rgb(0, 0, 170)); // klassisch Blau + assert_eq!(basic_color(4), Color::Rgb(170, 0, 0)); // klassisch Rot + assert_eq!(basic_color(14), Color::Rgb(255, 255, 85)); // Gelb + assert_eq!(basic_color(15), Color::Rgb(255, 255, 255)); // Weiß } } diff --git a/crates/tb-ui/src/terminal.rs b/crates/tb-ui/src/terminal.rs index 77493b2..67a6c8d 100644 --- a/crates/tb-ui/src/terminal.rs +++ b/crates/tb-ui/src/terminal.rs @@ -11,23 +11,47 @@ use std::io::{self, Write}; pub struct TerminalGuard { writer: W, raw: fn(bool) -> io::Result<()>, + #[cfg(windows)] + modes: Option, } impl TerminalGuard { pub fn enter(writer: W) -> io::Result { - Self::with_raw(writer, |on| { + // Farben tragen UI-/BASIC-Zustand und müssen auch ohne Farberkennung + // oder mit NO_COLOR als explizite RGB-Werte ausgegeben werden. + crossterm::style::force_color_output(true); + #[cfg(windows)] + let modes = ConsoleModes::capture()?; + #[cfg(windows)] + modes.enable_output()?; + #[allow(unused_mut)] + let mut guard = Self::with_raw(writer, |on| { if on { enable_raw_mode() } else { disable_raw_mode() } - }) + })?; + #[cfg(windows)] + { + guard.modes = Some(modes); + } + Ok(guard) } fn with_raw(writer: W, raw: fn(bool) -> io::Result<()>) -> io::Result { - let mut guard = Self { writer, raw }; + let mut guard = Self { + writer, + raw, + #[cfg(windows)] + modes: None, + }; guard.resume()?; Ok(guard) } fn resume(&mut self) -> io::Result<()> { + #[cfg(windows)] + if let Some(modes) = &self.modes { + modes.enable_output()?; + } (self.raw)(true)?; execute!( self.writer, @@ -38,15 +62,18 @@ impl TerminalGuard { ) } fn suspend(&mut self) -> io::Result<()> { - (self.raw)(false)?; - execute!( + let raw = (self.raw)(false); + let output = execute!( self.writer, ResetColor, Show, DisableMouseCapture, DisableBracketedPaste, LeaveAlternateScreen - ) + ); + #[cfg(windows)] + let output = output.and(self.modes.as_ref().map_or(Ok(()), ConsoleModes::restore)); + raw.and(output) } /// Foreground child owns cooked terminal input. Always restore the IDE, /// including spawn/wait errors and a child interrupted by Ctrl+C. @@ -91,6 +118,10 @@ impl TerminalGuard { } impl Drop for TerminalGuard { fn drop(&mut self) { + #[cfg(windows)] + if let Some(modes) = &self.modes { + let _ = modes.enable_output(); + } let _ = (self.raw)(false); let _ = execute!( self.writer, @@ -103,6 +134,58 @@ impl Drop for TerminalGuard { } } +// Crossterm stellt unter Windows Raw-/Mausflags pauschal zurück. Der Besitzer +// muss zusätzlich die tatsächlichen Eingabe- und Ausgabemodi wiederherstellen. +#[cfg(windows)] +#[derive(Debug)] +struct ConsoleModes([(std::os::windows::io::RawHandle, u32); 2]); +#[cfg(windows)] +#[link(name = "kernel32")] +extern "system" { + fn GetStdHandle(kind: u32) -> std::os::windows::io::RawHandle; + fn GetConsoleMode(handle: std::os::windows::io::RawHandle, mode: *mut u32) -> i32; + fn SetConsoleMode(handle: std::os::windows::io::RawHandle, mode: u32) -> i32; +} +#[cfg(windows)] +impl ConsoleModes { + fn capture() -> io::Result { + let mut modes = [(std::ptr::null_mut(), 0); 2]; + for (slot, kind) in modes.iter_mut().zip([(-10i32) as u32, (-11i32) as u32]) { + // Borrowed standard handles: valid until the terminal owner exits. + slot.0 = unsafe { GetStdHandle(kind) }; + if unsafe { GetConsoleMode(slot.0, &mut slot.1) } == 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(Self(modes)) + } + fn enable_output(&self) -> io::Result<()> { + // Crossterm caches ANSI support, but a Shell handoff restores the old + // output flags. Re-enable VT for each ownership cycle, including re-entry. + if crossterm::ansi_support::supports_ansi() + && unsafe { SetConsoleMode(self.0[1].0, self.0[1].1 | 0x0004) } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + fn restore(&self) -> io::Result<()> { + let mut result = Ok(()); + for (handle, mode) in self.0 { + if unsafe { SetConsoleMode(handle, mode) } == 0 && result.is_ok() { + result = Err(io::Error::last_os_error()); + } + } + result + } +} +#[cfg(windows)] +impl Drop for ConsoleModes { + fn drop(&mut self) { + let _ = self.restore(); + } +} + #[cfg(test)] mod tests { use super::*; @@ -149,7 +232,7 @@ mod tests { ("exit 7", false, 7), ( if cfg!(windows) { - "ping -n 30 127.0.0.1 >nul" + "for /L %i in (1,1,1000000000) do @rem" } else { "exec sleep 30" }, @@ -160,10 +243,13 @@ mod tests { let out = Output(Rc::default()); let copy = out.clone(); let mut guard = TerminalGuard::with_raw(out, raw).unwrap(); - assert_eq!( - guard.shell_with_interrupt(command, || abort).unwrap(), - expected - ); + let started = std::time::Instant::now(); + let code = guard.shell_with_interrupt(command, || abort).unwrap(); + if !abort { + assert_eq!(code, expected); + } + // Windows TerminateProcess liefert einen Code; Unix ein Signal. + assert!(started.elapsed() < std::time::Duration::from_secs(5)); assert_eq!(RAW.load(Ordering::SeqCst), 1); let text = String::from_utf8(copy.0.borrow().clone()).unwrap(); assert_eq!(text.matches("?1049h").count(), 2); @@ -187,4 +273,111 @@ mod tests { .unwrap() .contains("?25h")); } + /// Run explicitly on Windows; never counts as a Windows Terminal UI test. + #[cfg(windows)] + #[test] + #[ignore = "benötigt Windows mit echter Konsole; isolierter Kindprozess"] + fn windows_console_restores_modes() { + use std::os::windows::{io::AsRawHandle, process::CommandExt}; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + const CHILD: &str = "TB_CONSOLE_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "terminal::tests::windows_console_restores_modes", + "--ignored", + "--nocapture", + ]) + .env(CHILD, "1") + .creation_flags(0x00000010) // CREATE_NEW_CONSOLE: never touches the caller's console. + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!( + status.success(), + "Isolierte Windows-Konsolenprüfung: {status}" + ); + return; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("Windows-Konsolenprüfung hat die Frist überschritten"); + } + std::thread::sleep(Duration::from_millis(20)); + } + } + #[link(name = "kernel32")] + extern "system" { + fn SetStdHandle(kind: u32, handle: std::os::windows::io::RawHandle) -> i32; + } + let open = |name| { + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(name) + .unwrap() + }; + let input = open("CONIN$"); + let output = open("CONOUT$"); + // Standard streams started as NUL; bind to this child's own new console. + assert_ne!( + unsafe { SetStdHandle((-10i32) as u32, input.as_raw_handle()) }, + 0 + ); + assert_ne!( + unsafe { SetStdHandle((-11i32) as u32, output.as_raw_handle()) }, + 0 + ); + let original = ConsoleModes::capture().unwrap(); + // Non-default echo flags catch a reset-to-default masquerading as restore. + let mode = original.0[0].1 & !0x0004; // ENABLE_ECHO_INPUT + assert_ne!(unsafe { SetConsoleMode(input.as_raw_handle(), mode) }, 0); + let expected = ConsoleModes::capture().unwrap(); + let assert_restored = || assert_eq!(ConsoleModes::capture().unwrap().0, expected.0); + for (command, abort, code) in [ + ("exit 0", false, 0), + ("exit 7", false, 7), + ("for /L %i in (1,1,1000000000) do @rem", true, 0), + ] { + let mut guard = TerminalGuard::enter(io::stdout()).unwrap(); + guard.suspend().unwrap(); + assert_restored(); + guard.resume().unwrap(); + if crossterm::ansi_support::supports_ansi() { + assert_ne!(ConsoleModes::capture().unwrap().0[1].1 & 0x0004, 0); + } + let result = guard.shell_with_interrupt(command, || abort).unwrap(); + if !abort { + assert_eq!(result, code); + } + assert!(crossterm::terminal::is_raw_mode_enabled().unwrap()); + drop(guard); + assert_restored(); + } + let error = (|| -> io::Result<()> { + let _guard = TerminalGuard::enter(io::stdout())?; + Err(io::Error::other("Erzwungener Renderfehler")) + })(); + assert!(error.is_err()); + assert_restored(); + struct Broken; + impl Write for Broken { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::Error::other("Initialisierung")) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + assert!(TerminalGuard::enter(Broken).is_err()); + assert_restored(); + } } diff --git a/docs/ide-referenz.md b/docs/ide-referenz.md index 6cfa05d..6104316 100644 --- a/docs/ide-referenz.md +++ b/docs/ide-referenz.md @@ -102,24 +102,23 @@ Laufzeit-Defaults kompilierter Programme weichen ab (Titel Weiß auf Schwarz). ## 4a. Farbabbildung und Kontrast (Phase 6) -Die DOS-Farbnummern in Options→Display bleiben erhalten. Die IDE verwendet -bei `COLORTERM=truecolor`/`24bit` explizite RGB-Werte der klassischen -16-Farben-Palette; bei `TERM=…256color` feste Einträge im Farbwürfel und der -Graurampe außerhalb der ersten 16 ANSI-Farben. Ein generisches `COLORTERM` -verdeckt diese 256-Farben-Erkennung nicht. Auf Windows verwendet die IDE -zusätzlich die ANSI-Fähigkeitserkennung des vorhandenen Terminaladapters. -Die Erkennung erfolgt einmal pro Prozess; nach Profiländerungen neu starten. - -Damit hängt das Standardtheme bei expliziter Farbausgabe nicht von einer -pastellfarbenen ANSI-Palette ab. Beispielwerte: Codehintergrund `#0000AA`, -Codeschrift `#AAAAAA`, aktiver Titel `#FFFFFF` auf `#AA00AA`. DOS-Farbe 6 -ist Braun (`#AA5500`), nicht das frei konfigurierte ANSI-Gelb. Der -256-Farben-Fallback nähert diese Werte an (`#0000AF`, `#A8A8A8`, `#AF00AF`). -Die globale Terminalpalette wird nicht verändert. Unbekannte beziehungsweise -reine 16-Farb-Terminals verwenden weiterhin die ANSI-Zuordnung; dort ist ein -geeignetes Terminalprofil nötig. `NO_COLOR`, Transparenz, Farbfilter und -Inaktivitätsdimmung des Emulators können die Darstellung zusätzlich verändern; -die IDE überschreibt solche Benutzereinstellungen nicht. +Die DOS-Farbnummern in Options→Display bleiben erhalten. IDE, Formularvorschau +und BASIC-Ausgabe verwenden dieselbe feste klassische RGB-Palette. Jede +Farbe wird als 24-Bit-Vorder-/Hintergrundwert ausgegeben, unabhängig von +`TERM`, `COLORTERM`, `NO_COLOR` oder der ANSI-Palette des Terminalprofils. +Es gibt keinen Rückfall auf Index- oder Profilfarben. Die globale +Terminalpalette wird nicht verändert; gespeichert werden weiterhin +DOS-Farbnummern, und BASIC bestimmt seine eigenen ForeColor/BackColor-Werte. + +Beispielwerte: Codehintergrund `#0000AA`, Codeschrift `#AAAAAA`, aktiver +Titel `#FFFFFF` auf `#AA00AA`. DOS-Farbe 6 ist Braun (`#AA5500`). +Fensterflächen werden vor dem Zeichnen geleert, damit Desktop-Füllzeichen +und Inhalte verdeckter Fenster keine scheinbar helleren Hintergründe erzeugen. + +Die Ausgabe setzt einen Terminalemulator mit 24-Bit-Farbunterstützung voraus. +Fehlende RGB-Unterstützung bleibt ein offener Zielbefund. Transparenz, +Farbfilter und Inaktivitätsdimmung sind nachgelagerte Emulatorfunktionen; +sie werden für die visuelle Farbprüfung getrennt protokolliert. ### Referenzfundstellen @@ -144,7 +143,7 @@ Palette. Ansichten belegen Flächen und Rollen, keine heutige Terminalausgabe. | Deaktivierte Beschriftungen | Nicht ausreichend durch diese Ansichten belegt | Lesbares Schwarz auf Grau, Menüs zusätzlich `×` und kursiv | | Hilfe und Debuggermarkierungen | In diesen Ansichten nicht belegt | Dunkelblaue Hilfetitel/Links auf Grau; Fokus Weiß auf Blau; Breakpoints Weiß auf Rot, Ausführungszeile Schwarz auf Gelb | -Die Standardpaare müssen bei RGB- und 256-Farben-Ausgabe mindestens 4,5:1 +Die Standardpaare müssen bei RGB-Ausgabe mindestens 4,5:1 Textkontrast erreichen; notwendige nichttextuelle Fokusmarkierungen mindestens 3:1. Die Zelltests prüfen auch Rahmen und Handles auf mindestens 4,5:1 gegen ihren eigenen Hintergrund. Benutzerauswahl in Display bleibt frei; @@ -154,16 +153,16 @@ Designer-Werkzeuge und Handles die IDE-Farben verwenden. Die sRGB-Kontrastberechnung ergibt für die wichtigsten Standardpaare: -| Rolle | RGB | 256 Farben | -|---|---|---| -| Code / Hilfelink | 5,72:1 | 5,45:1 | -| Aktiver Titel | 6,38:1 | 6,10:1 | -| Menü / Dialog / deaktivierter Text | 9,04:1 | 8,83:1 | -| Status / Werkzeuge | 7,33:1 | 7,75:1 | -| Auswahl | 21,00:1 | 21,00:1 | -| Linkfokus / Debugger | 13,29:1 | 12,97:1 | -| Breakpoint | 7,75:1 | 7,44:1 | -| Ausführung / Handle | 19,69:1 | 19,72:1 | +| Rolle | RGB-Kontrast | +|---|---| +| Code / Hilfelink | 5,72:1 | +| Aktiver Titel | 6,38:1 | +| Menü / Dialog / deaktivierter Text | 9,04:1 | +| Status / Werkzeuge | 7,33:1 | +| Auswahl | 21,00:1 | +| Linkfokus / Debugger | 13,29:1 | +| Breakpoint | 7,75:1 | +| Ausführung / Handle | 19,69:1 | ### Prüffolge für Change 05 @@ -188,10 +187,10 @@ Linux amd64/xterm, Linux amd64/VTE, Linux arm64/xterm und Linux arm64/VTE: Farbbeschriftungen und Handles; ForeColor/BackColor der Vorschau bleiben unabhängig vom IDE-Theme. 6. Dieselben Ansichten mit absichtlich abweichender ANSI-Palette prüfen. - RGB-/256-Farben der IDE müssen stabil bleiben. Transparenz und + RGB-Farben von IDE und BASIC-Ausgabe müssen stabil bleiben. Transparenz und Inaktivitätsdimmung getrennt ein-/ausschalten und Abweichungen als - Emulatorverhalten dokumentieren. Für 16-Farb-Fallback das tatsächlich - geeignete Profil und verbleibende Unterschiede festhalten. + Emulatorverhalten dokumentieren. Auch ohne `COLORTERM`, mit `TERM=vt100` + und mit `NO_COLOR=1` muss die Anwendung dieselben RGB-Werte ausgeben. 7. Options ändern, speichern und neu starten; Auswahl muss erhalten sein. IDE beenden: globale Palette und normale Shell-Anzeige müssen erhalten sein. diff --git a/openspec/changes/phase-6-05-plattformmatrix/design.md b/openspec/changes/phase-6-05-plattformmatrix/design.md index 22aa012..6e3fa4d 100644 --- a/openspec/changes/phase-6-05-plattformmatrix/design.md +++ b/openspec/changes/phase-6-05-plattformmatrix/design.md @@ -19,6 +19,8 @@ Phase 5 verwendete crossterm-Ereignisse, ratatui-TestBackend und `tests/support/ 7. Den ergänzenden Theme-Change 05a vor Abschluss der visuellen Matrix berücksichtigen. Dessen Referenzansichten und Kontrastprüfungen mit tatsächlicher Revision, Farbprofil und Standard-/abweichender ANSI-Palette in jeder Pflichtzelle prüfen; vor einer Theme-Korrektur erhobene betroffene Bilder erneut aufnehmen. Diese Nachweise gehen mit der bestehenden Matrix an 06/07. +8. Benutzerbefund vom 07.09.2026: ANSI-/256-Fallback entfernen und die vorhandene Farbabbildung in tb-ui für IDE und BASIC gemeinsam nutzen. RGB-Ausgabe auch bei fehlenden Farbfähigkeitsvariablen und NO_COLOR erzwingen. Fenster vor Block/Paragraph leeren, da diese nur Stile setzen und sonst Desktopmuster oder verdeckte Texte durchscheinen. Regressionen prüfen tatsächliche Leerzeichen, Auswahl/Neuzeichnen, absolute Farbwerte und ausgegebene Escape-Sequenzen. Frühere visuelle Nachweise gelten nicht für diesen korrigierten Stand. + ## Risks / Trade-offs - CI ohne interaktiven Desktop → automatisierte Zieltests plus zugeordnete manuelle Emulatornachweise, keine Gleichsetzung beider Kategorien. diff --git a/openspec/changes/phase-6-05-plattformmatrix/proposal.md b/openspec/changes/phase-6-05-plattformmatrix/proposal.md index 0e2efa2..ca383fa 100644 --- a/openspec/changes/phase-6-05-plattformmatrix/proposal.md +++ b/openspec/changes/phase-6-05-plattformmatrix/proposal.md @@ -18,7 +18,8 @@ Die bisherige headless Abnahme und der lokale Unix-PTY-Test beweisen nicht die e ### Modified Capabilities -Keine. Bestehende IDE- und Host-Eingabeverträge bleiben maßgeblich. +- `ide-oberflaeche`: Durchgehend absolute RGB-Farben und vollständig geleerte Fensterflächen; Benutzerkorrektur vom 07.09.2026. +- `textbildschirm`: Dieselbe absolute DOS-RGB-Palette für programmbestimmte BASIC-Farben und Formularvorschau. ## Impact diff --git a/openspec/changes/phase-6-05-plattformmatrix/tasks.md b/openspec/changes/phase-6-05-plattformmatrix/tasks.md index da2b71e..5ab64d7 100644 --- a/openspec/changes/phase-6-05-plattformmatrix/tasks.md +++ b/openspec/changes/phase-6-05-plattformmatrix/tasks.md @@ -1,8 +1,8 @@ ## 1. Prüfpfade vorbereiten -- [ ] 1.1 Verbindliche Matrix und kurze Bediensequenzen für die vier Targets anlegen; Windows Terminal, Terminal.app sowie xterm/VTE auf beiden Linux-Architekturen müssen konkrete Versions-/Revisionsfelder und erwartete Resultate besitzen. +- [x] 1.1 Verbindliche Matrix und kurze Bediensequenzen für die vier Targets anlegen; Windows Terminal, Terminal.app sowie xterm/VTE auf beiden Linux-Architekturen müssen konkrete Versions-/Revisionsfelder und erwartete Resultate besitzen. - [ ] 1.2 Bestehenden Unix-PTY-Test und passenden Windows-Konsolen-/ConPTY-Test für Shell, Abbruch und Wiederherstellung bereitstellen; positive Läufe und erzwungene Fehler dürfen keinen beschädigten Terminalmodus hinterlassen. -- [ ] 1.3 Die gemeinsame TBL-Verbraucherprobe aus 03 samt fester Prüfsumme in jede der vier Zielabnahmen aufnehmen (quellfreies TBC/EXE, RUN, tatsächliches Host-Target); IDE-/Programmprüfungen für F1–F12, Modifikatoren, Maus, Unicode/Attribute und Resize nachvollziehbar auflisten; jeder Fall muss sichtbaren Effekt und Ausschluss doppelter BASIC-Zustellung prüfen. +- [x] 1.3 Die gemeinsame TBL-Verbraucherprobe aus 03 samt fester Prüfsumme in jede der vier Zielabnahmen aufnehmen (quellfreies TBC/EXE, RUN, tatsächliches Host-Target); IDE-/Programmprüfungen für F1–F12, Modifikatoren, Maus, Unicode/Attribute und Resize nachvollziehbar auflisten; jeder Fall muss sichtbaren Effekt und Ausschluss doppelter BASIC-Zustellung prüfen. ## 2. Tatsächliche Zielabnahme @@ -16,3 +16,11 @@ - [ ] 3.1 Terminalgrenzen und verifizierte Ersatzwege dokumentieren; kein Produktfehler oder fehlender Prüfrechner darf als bestandene Matrixzelle erscheinen. - [ ] 3.2 Automatisierbare Zielprüfungen an 06 übergeben und Matrixbericht fertigstellen; alle Pflichtzellen, Revisionen, vier Ergebnisse derselben TBL-Verbraucherprobe und tatsächlichen Ergebnisse müssen vor Abschluss vorliegen. Die weiterhin offenen Archivaufgaben 05a/1.2 (konkretes Benutzerprofil, frische/gespeicherte Optionen, Transparenz/Dimmung), 05a/3.2 (visuelle Matrix) und 05a/3.3 (befundfreie Schlussverifizierung) müssen hier tatsächlich abgeschlossen werden. Die visuellen Nachweise müssen das umgesetzte Theme aus 05a einschließlich Profil-/Farbmodus und relevanter UI-Zustände abdecken. Native Prüfsysteme dürfen separat vom zentralen Buildrunner betrieben und Nachweise dokumentiert manuell erhoben werden. + +Arbeitsstand 07.09.2026: Matrix/Bedienfolgen in `docs/plattformmatrix.md`, Eingabeprobe `tests/platform/input.frm`, gemeinsamer Aufruf `tests/support/platform-abnahme.py`. 1.2 ist implementiert und für Windows cross-geprüft, bleibt bis zum tatsächlichen Windows-Konsolenlauf offen. Automatisierte macOS-arm64-Nachweise liegen unter `evidence/2026-09-07-macos-arm64`; sie schließen keine manuelle Terminalzelle. Details und offene Befunde: `verification.md`. + +RGB-Korrektur nach Benutzerbefund vom 07.09.2026 gehört zu 2.5: keine +ANSI-/256-Fallbackfarben; gemeinsame feste RGB-Palette für IDE und BASIC; +Fensterflächen ohne durchscheinendes Desktopmuster. Neue Delta-Spezifikationen +in `ide-oberflaeche` und `textbildschirm` ersetzen den früheren Fallbackvertrag. +Die betroffene visuelle Zielprüfung bleibt bis zur echten Wiederholung offen. diff --git a/tests/support/ide-execution-pty.py b/tests/support/ide-execution-pty.py index b422070..ee369fa 100644 --- a/tests/support/ide-execution-pty.py +++ b/tests/support/ide-execution-pty.py @@ -2,6 +2,7 @@ """Headless Unix PTY smoke: real terminal handoff, child Ctrl+C and IDE resume. Run after cargo build -p tb-ide: python3 tests/support/ide-execution-pty.py """ +import argparse import fcntl import os from pathlib import Path @@ -13,10 +14,10 @@ import tempfile import termios import time -binary = Path(__file__).resolve().parents[2] / 'target/debug/tb' -def exercise(abort=False, file_shell=False): + +def exercise(binary, transcript_dir=None, abort=False, file_shell=False): with tempfile.TemporaryDirectory(prefix='tb-pty-') as directory: source = Path(directory) / 'main.bas' command = "printf '\\nWAITING_CHILD\\n'; exec sleep 30" if abort else "printf '\\nSHELL_PROOF\\n'; exit 7" @@ -32,7 +33,7 @@ def exercise(abort=False, file_shell=False): os.dup2(slave, fd) os.close(master) os.close(slave) - os.environ['TERM'] = 'xterm-256color' + os.environ.setdefault('TERM', 'xterm-256color') os.environ['XDG_CONFIG_HOME'] = directory # Keep the controlling terminal alive while checking restoration. signal.signal(signal.SIGINT, lambda *_: None) @@ -83,7 +84,17 @@ def exercise(abort=False, file_shell=False): transcript.extend(os.read(master, 65536)) assert transcript.count(b'?1049h') >= 2 assert transcript.count(b'?1049l') >= 2 + # Tatsächliche Ausgabe muss auch bei NO_COLOR/fehlender Erkennung + # dieselben absoluten IDE-Farben enthalten, ohne Palettenänderung. + assert b'38;2;170;170;170' in transcript + assert b'48;2;0;0;170' in transcript + assert b'48;2;170;0;170' in transcript + assert b'38;5;' not in transcript and b'48;5;' not in transcript + assert b'\x1b]' not in transcript finally: + if transcript_dir: + name = 'file-shell' if file_shell else 'shell-abort' if abort else 'shell-exit' + (transcript_dir / (name + '.ansi')).write_bytes(transcript) if not reaped: os.killpg(pid, signal.SIGKILL) os.waitpid(pid, 0) @@ -91,6 +102,18 @@ def exercise(abort=False, file_shell=False): os.close(slave) -for kwargs in ({}, {'abort': True}, {'file_shell': True}): - exercise(**kwargs) - print('PASS', kwargs or {'shell_exit': 7}) +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', type=Path, default=Path(__file__).resolve().parents[2] / 'target/debug/tb') + parser.add_argument('--transcript-dir', type=Path) + args = parser.parse_args() + binary = args.binary.resolve(strict=True) + if args.transcript_dir: + args.transcript_dir.mkdir(parents=True, exist_ok=True) + for kwargs in ({}, {'abort': True}, {'file_shell': True}): + exercise(binary, args.transcript_dir, **kwargs) + print('PASS', kwargs or {'shell_exit': 7}) + + +if __name__ == '__main__': + main()