453 lines
16 KiB
Diff
453 lines
16 KiB
Diff
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/terminal.rs b/crates/tb-ui/src/terminal.rs
|
||
index 77493b2..a8204d4 100644
|
||
--- a/crates/tb-ui/src/terminal.rs
|
||
+++ b/crates/tb-ui/src/terminal.rs
|
||
@@ -11,23 +11,44 @@ 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| {
|
||
+ #[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 +59,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 +115,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 +131,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 +229,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 +240,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 +270,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/tests/support/ide-execution-pty.py b/tests/support/ide-execution-pty.py
|
||
index b422070..d43c472 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"
|
||
@@ -84,6 +85,9 @@ def exercise(abort=False, file_shell=False):
|
||
assert transcript.count(b'?1049h') >= 2
|
||
assert transcript.count(b'?1049l') >= 2
|
||
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 +95,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()
|