Files
TerminalBasic/openspec/changes/archive/2026-09-07-phase-6-05-plattformmatrix/evidence/2026-09-07-macos-arm64/working-tree.patch

453 lines
16 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/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()