Phase 6: Plattformprüfungen und absolute RGB-Farben, Change 05 archivieren

This commit is contained in:
2026-09-07 21:12:25 +02:00
parent 5aa920b768
commit 796564795a
55 changed files with 3772 additions and 200 deletions

View File

@@ -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ß
}
}

View File

@@ -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();
}
}