Phase 6: Plattformprüfungen und absolute RGB-Farben, Change 05 archivieren
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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ß
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user