Files

944 lines
40 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<u16> = 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<W: Write> {
writer: W,
raw: fn(bool) -> io::Result<()>,
+ #[cfg(windows)]
+ modes: Option<ConsoleModes>,
}
impl<W: Write> TerminalGuard<W> {
pub fn enter(writer: W) -> io::Result<Self> {
- 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<Self> {
- 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<W: Write> TerminalGuard<W> {
)
}
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<W: Write> TerminalGuard<W> {
}
impl<W: Write> Drop for TerminalGuard<W> {
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<W: Write> Drop for TerminalGuard<W> {
}
}
+// 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<Self> {
+ 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<usize> {
+ 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 F1F12, 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 F1F12, 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()