Phase 5: IDE-Rahmen implementieren, synchronisieren und archivieren

This commit is contained in:
2026-09-06 16:28:16 +02:00
parent 687fc230ec
commit df85a4b7b2
27 changed files with 4215 additions and 34 deletions

View File

@@ -18,3 +18,4 @@ tb-ui.workspace = true
ratatui.workspace = true
crossterm.workspace = true
anyhow.workspace = true
unicode-width.workspace = true

1757
crates/tb-ide/src/app.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,337 @@
//! Eine Befehlsliste für Menü, Tastatur und Statusleiste.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
NewProject,
OpenProject,
SaveProject,
NewForm,
NewModule,
AddFile,
RemoveFile,
SaveFile,
SaveAs,
LoadText,
SaveText,
Print,
Shell,
Exit,
Undo,
Cut,
Copy,
Paste,
Clear,
NewSub,
NewFunction,
Events,
Code,
Form,
NextStatement,
OutputScreen,
IncludedFile,
IncludedLines,
MenuBar,
Grid,
Find,
SelectedText,
FindNext,
Replace,
Start,
Restart,
Continue,
CommandLine,
MakeExe,
MakeLibrary,
Startup,
AddWatch,
InstantWatch,
Watchpoint,
DeleteWatch,
DeleteWatches,
Trace,
History,
Breakpoint,
ClearBreakpoints,
BreakErrors,
SetStatement,
RunToCursor,
Step,
ProcedureStep,
HistoryBack,
HistoryForward,
Display,
Paths,
RightMouse,
SaveOptions,
SyntaxChecking,
NewWindow,
Arrange,
Calls,
Debug,
HelpWindow,
Immediate,
Output,
Project,
Palette,
MenuDesign,
Toolbox,
Tool(&'static str),
HelpIndex,
HelpContents,
Keyboard,
Topic,
UsingHelp,
Tutorial,
About,
NextWindow,
PreviousWindow,
CloseWindow,
MoveWindow,
SizeWindow,
Minimize,
Maximize,
Restore,
ControlMenu,
FocusWindow(u64),
}
impl Command {
pub fn feature_phase(self) -> Option<u8> {
use Command::*;
match self {
Cut | Copy | Paste | Clear | NewSub | NewFunction | IncludedFile | IncludedLines
| Find | SelectedText | FindNext | Replace => Some(3),
Print | Shell | Start | Restart | Continue | CommandLine | OutputScreen => Some(4),
Events | Grid | Palette | MenuDesign | Toolbox | Tool(_) => Some(5),
NextStatement | AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches
| Trace | History | Breakpoint | ClearBreakpoints | BreakErrors | SetStatement
| RunToCursor | Step | ProcedureStep | HistoryBack | HistoryForward => Some(6),
HelpIndex | HelpContents | Keyboard | Topic | UsingHelp | Tutorial => Some(7),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Item {
/// & markiert das sichtbare Mnemonic, … einen Dialog.
pub label: &'static str,
pub command: Option<Command>,
}
impl Item {
pub fn text(&self) -> String {
self.label.replace('&', "")
}
pub fn mnemonic(&self) -> Option<char> {
self.label
.split_once('&')?
.1
.chars()
.next()
.map(|c| c.to_ascii_lowercase())
}
}
pub struct Menu {
pub title: &'static str,
pub mnemonic: char,
pub items: Vec<Item>,
}
pub fn menus(designer: bool) -> Vec<Menu> {
use Command::*;
let item = |label, command| Item {
label,
command: Some(command),
};
let sep = || Item {
label: "────────",
command: None,
};
let menu = |title, mnemonic, items| Menu {
title,
mnemonic,
items,
};
let mut result = vec![
menu(
"File",
'f',
vec![
item("&New Project", NewProject),
item("&Open Project…", OpenProject),
item("Save &Project", SaveProject),
sep(),
item("New &Form", NewForm),
item("New &Module…", NewModule),
item("&Add File…", AddFile),
item("&Remove File…", RemoveFile),
item("&Save File", SaveFile),
item("Sa&ve File As…", SaveAs),
sep(),
item("&Load Text…", LoadText),
item("Save &Text…", SaveText),
sep(),
item("Pr&int…", Print),
item("S&hell", Shell),
sep(),
item("E&xit", Exit),
],
),
menu(
"Edit",
'e',
vec![
item("&Undo", Undo),
sep(),
item("Cu&t", Cut),
item("&Copy", Copy),
item("&Paste", Paste),
item("C&lear", Clear),
sep(),
item("New &Sub…", NewSub),
item("New &Function…", NewFunction),
item("&Event Procedures…", Events),
],
),
menu(
"View",
'v',
vec![
item("&Code…", Code),
item("&Form…", Form),
sep(),
item("&Next Statement", NextStatement),
item("&Output Screen", OutputScreen),
sep(),
item("&Included File", IncludedFile),
item("Included &Lines", IncludedLines),
],
),
];
if designer {
result[2]
.items
.extend([sep(), item("&Menu Bar", MenuBar), item("&Grid Lines", Grid)]);
result.push(menu(
"Tools",
't',
vec![
item("&Check Box", Tool("CheckBox")),
item("C&ombo Box", Tool("ComboBox")),
item("Command &Button", Tool("CommandButton")),
item("&Dir List", Tool("DirListBox")),
item("D&rive List", Tool("DriveListBox")),
item("&File List", Tool("FileListBox")),
item("Fr&ame", Tool("Frame")),
item("&HScrollBar", Tool("HScrollBar")),
item("&Label", Tool("Label")),
item("L&ist Box", Tool("ListBox")),
item("O&ption Button", Tool("OptionButton")),
item("Pict&ure Box", Tool("PictureBox")),
item("&Text Box", Tool("TextBox")),
item("Ti&mer", Tool("Timer")),
item("&VScrollBar", Tool("VScrollBar")),
],
));
} else {
result.extend([
menu(
"Search",
's',
vec![
item("&Find…", Find),
item("&Selected Text", SelectedText),
item("&Repeat Last Find", FindNext),
item("&Change…", Replace),
],
),
menu(
"Run",
'r',
vec![
item("&Start", Start),
item("&Restart", Restart),
item("&Continue", Continue),
item("Modify COMMAND&$…", CommandLine),
sep(),
item("Make &EXE File…", MakeExe),
item("Make &Library…", MakeLibrary),
sep(),
item("Set Start-&up File…", Startup),
],
),
menu(
"Debug",
'd',
vec![
item("&Add Watch…", AddWatch),
item("&Instant Watch…", InstantWatch),
item("&Watchpoint…", Watchpoint),
item("&Delete Watch…", DeleteWatch),
item("Delete A&ll Watch", DeleteWatches),
sep(),
item("&Trace On", Trace),
item("&History On", History),
sep(),
item("Toggle &Breakpoint", Breakpoint),
item("&Clear All Breakpoints", ClearBreakpoints),
item("Break on &Errors", BreakErrors),
item("Set &Next Statement", SetStatement),
],
),
]);
}
result.push(menu(
"Options",
'o',
vec![
item("&Display…", Display),
item("Set &Paths…", Paths),
item("&Right Mouse…", RightMouse),
item("&Save…", SaveOptions),
item("S&yntax Checking", SyntaxChecking),
],
));
result.push(if designer {
menu(
"Window",
'w',
vec![
item("&Color Palette", Palette),
item("&Menu Design Window", MenuDesign),
item("&Toolbox", Toolbox),
item("&Help", HelpWindow),
],
)
} else {
menu(
"Window",
'w',
vec![
item("&New Window", NewWindow),
item("&Arrange All", Arrange),
sep(),
item("&Calls", Calls),
item("&Debug", Debug),
item("&Help", HelpWindow),
item("&Immediate", Immediate),
item("&Output", Output),
item("&Project", Project),
sep(),
],
)
});
result.push(menu(
"Help",
'h',
vec![
item("&Index", HelpIndex),
item("&Contents", HelpContents),
sep(),
item("&Keyboard", Keyboard),
sep(),
item("&Topic", Topic),
item("&Using Help", UsingHelp),
item("Tuto&rial", Tutorial),
sep(),
item("&About…", About),
],
));
result
}

View File

@@ -123,6 +123,9 @@ pub struct Project {
pub include_paths: Vec<PathBuf>,
}
impl Project {
pub fn directory(&self) -> &Path {
&self.base
}
pub fn new(base: &Path) -> Result<Self> {
Ok(Self {
base: identity(base)?,
@@ -538,6 +541,25 @@ impl Project {
);
Ok(target)
}
/// Gemeinsame Zielprüfung für die Exportdialoge; schreibt keine Datei.
pub fn validate_export_target(&self, destination: &Destination) -> Result<PathBuf> {
let path = self.target(&destination.path)?;
ensure!(
path.parent().is_some_and(Path::is_dir),
"Zielverzeichnis fehlt"
);
ensure!(
self.path.as_deref() != Some(&path),
"Projektdatei ist kein Exportziel"
);
ensure!(
self.documents.values().all(|d| d.source_path != path),
"Geöffnetes Dokument ist kein Exportziel"
);
self.protect_binary(&path)?;
check_destination(&path, None, destination.overwrite)?;
Ok(path)
}
fn protect_binary(&self, path: &Path) -> Result<()> {
ensure!(
!fs::read(path).is_ok_and(|b| b.starts_with(&[0xfc, 0x08, 1, 0])),
@@ -755,7 +777,12 @@ fn check_destination(path: &Path, expected: Option<&[u8]>, overwrite: bool) -> R
}
/// Ersetzt erst nach erfolgreichem Schreiben und flush/sync; temporäre Datei im selben Verzeichnis.
fn atomic_write(path: &Path, bytes: &[u8], expected: Option<&[u8]>, overwrite: bool) -> Result<()> {
pub(crate) fn atomic_write(
path: &Path,
bytes: &[u8],
expected: Option<&[u8]>,
overwrite: bool,
) -> Result<()> {
check_destination(path, expected, overwrite)?;
let parent = path
.parent()

View File

@@ -0,0 +1,97 @@
//! Konkreter Vertrag für die spätere native Erzeugung, ohne Phase-5-Dateiausgabe.
use crate::documents::{Destination, DocumentId, Project};
use anyhow::{ensure, Result};
use std::path::PathBuf;
use tb_vm::project_io::{has_extension, Manifest};
pub const UNAVAILABLE: &str = "Native Erzeugung folgt in Phase 6";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Artifact {
Executable,
Library,
}
impl Artifact {
pub fn label(self) -> &'static str {
match self {
Self::Executable => "Natives Standalone-Executable",
Self::Library => "Native Systembibliothek",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectStamp {
pub path: Option<PathBuf>,
pub manifest: Manifest,
pub revisions: Vec<(DocumentId, u64)>,
}
impl ProjectStamp {
pub fn capture(project: &Project) -> Self {
Self {
path: project.path().map(PathBuf::from),
manifest: project.manifest().clone(),
revisions: project
.documents()
.map(|(id, d)| (id, d.revision()))
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportRequest {
pub project: ProjectStamp,
pub artifact: Artifact,
pub system: String,
pub architecture: String,
pub path: PathBuf,
pub overwrite: bool,
}
impl ExportRequest {
pub fn validate(
project: &Project,
artifact: Artifact,
system: &str,
architecture: &str,
destination: &Destination,
) -> Result<Self> {
ensure!(
["linux", "macos", "windows"].contains(&system),
"Unbekanntes Zielsystem"
);
ensure!(
["x86_64", "aarch64"].contains(&architecture),
"Unbekannte Zielarchitektur"
);
ensure!(
!has_extension(&destination.path, "tbc"),
"TBC ist kein natives Exportartefakt"
);
let path = project.validate_export_target(destination)?;
Ok(Self {
project: ProjectStamp::capture(project),
artifact,
system: system.into(),
architecture: architecture.into(),
path,
overwrite: destination.overwrite,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExportStatus {
Ready,
Running,
Success,
Failed(String),
Cancelled,
}
impl ExportStatus {
pub fn text(&self) -> String {
match self {
Self::Ready => "Geprüft".into(),
Self::Running => "Erzeugung läuft".into(),
Self::Success => "Erfolgreich erzeugt".into(),
Self::Failed(e) => format!("Erzeugung fehlgeschlagen: {e}"),
Self::Cancelled => "Erzeugung abgebrochen".into(),
}
}
}

View File

@@ -1,2 +1,8 @@
//! Testbare IDE-Zustände; Terminalbedienung wird vom IDE-Rahmen angebunden.
pub mod app;
pub mod commands;
pub mod documents;
pub mod export;
pub mod options;
pub mod render;
pub mod terminal;

View File

@@ -1,11 +1,33 @@
//! `tb` — die integrierte Entwicklungsumgebung von Terminal Basic (TUI).
//!
//! Nachbildung der klassischen DOS-IDE: Menüleiste, Editor mit
//! Syntaxprüfung pro Zeile, Formular-Designer, Direktfenster,
//! Debugger (Einzelschritt, Breakpoints, Überwachungsausdrücke).
//! Siehe PLAN.md, Phase 5.
use anyhow::{ensure, Result};
use crossterm::event;
use ratatui::{backend::CrosstermBackend, Terminal};
use std::{
io::{self, IsTerminal},
path::PathBuf,
};
use tb_ide::{app::App, options, terminal::TerminalGuard};
fn main() -> anyhow::Result<()> {
println!("tb — Terminal Basic IDE (Projektrahmen, noch ohne Funktion)");
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
if args.iter().any(|s| s == "--help" || s == "-h") {
println!("tb [Projekt.mak|Modul.bas|Formular.frm]\nTerminal Basic IDE · mindestens 80×25\nF11: Menü · Alt+F4: Beenden");
return Ok(());
}
ensure!(args.len() <= 1, "Aufruf: tb [Projektdatei]");
ensure!(
io::stdin().is_terminal() && io::stdout().is_terminal(),
"tb benötigt ein interaktives Terminal; --help zeigt den Aufruf"
);
let base = std::env::current_dir()?;
let mut app = App::new(&base, options::config_path()?, crossterm::terminal::size()?)?;
if let Some(path) = args.first() {
app.load_initial_project(PathBuf::from(path))?;
}
let _guard = TerminalGuard::enter(io::stdout())?;
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
while !app.quit {
terminal.draw(|f| app.render(f))?;
app.handle(event::read()?);
}
Ok(())
}

View File

@@ -0,0 +1,187 @@
use anyhow::{anyhow, ensure, Context, Result};
use std::{
fs,
path::{Path, PathBuf},
};
use unicode_width::UnicodeWidthChar;
pub const ELEMENTS: [&str; 7] = [
"Menü",
"Desktop",
"Code",
"Aktiver Titel",
"Rahmen",
"Dialog/Projekt",
"Status",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Options {
pub colors: [(u8, u8); 7],
pub desktop: char,
pub tab_width: usize,
pub include_paths: Vec<PathBuf>,
pub source_dir: PathBuf,
pub output_dir: PathBuf,
pub right_help: bool,
pub syntax_checking: bool,
}
impl Default for Options {
fn default() -> Self {
Self {
colors: [(0, 7), (7, 0), (7, 1), (15, 5), (0, 7), (0, 7), (0, 3)],
desktop: '',
tab_width: 8,
include_paths: vec![],
source_dir: PathBuf::new(),
output_dir: PathBuf::new(),
right_help: true,
syntax_checking: true,
}
}
}
pub fn config_path() -> Result<PathBuf> {
let base = if cfg!(windows) {
std::env::var_os("APPDATA").map(PathBuf::from)
} else if let Some(path) = std::env::var_os("XDG_CONFIG_HOME") {
Some(PathBuf::from(path))
} else {
std::env::var_os("HOME").map(|p| {
PathBuf::from(p).join(if cfg!(target_os = "macos") {
"Library/Application Support"
} else {
".config"
})
})
}
.ok_or_else(|| anyhow!("Benutzer-Konfigurationsverzeichnis fehlt"))?;
ensure!(
base.is_absolute(),
"Konfigurationsverzeichnis muss absolut sein"
);
Ok(base.join("TerminalBasic/options.ini"))
}
impl Options {
pub fn validate(&self) -> Result<()> {
ensure!(
self.colors.iter().all(|(fg, bg)| *fg < 16 && *bg < 16),
"Farben müssen zwischen 0 und 15 liegen"
);
ensure!(
(1..=32).contains(&self.tab_width),
"Tabweite muss zwischen 1 und 32 liegen"
);
ensure!(
self.desktop.width() == Some(1) && !self.desktop.is_control(),
"Desktopzeichen muss genau eine Terminalzelle belegen"
);
for path in self
.include_paths
.iter()
.chain([&self.source_dir, &self.output_dir])
{
if path.as_os_str().is_empty() {
continue;
}
ensure!(
path.is_absolute()
&& path
.to_str()
.is_some_and(|s| !s.contains(['\n', '\r', '\0', '|'])),
"Optionspfade müssen absolute Verzeichnispfade sein (ohne | oder Zeilenumbruch)"
);
}
Ok(())
}
pub fn load(path: &Path) -> (Self, Vec<String>, Option<Vec<u8>>) {
let bytes = match fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return (Self::default(), vec![], None)
}
Err(e) => {
return (
Self::default(),
vec![format!("{}: {e}", path.display())],
None,
)
}
};
let Ok(text) = std::str::from_utf8(&bytes) else {
return (
Self::default(),
vec!["Optionsdatei ist nicht UTF-8; Defaults aktiv".into()],
Some(bytes),
);
};
let mut value = Self::default();
let mut errors = vec![];
if !text.lines().any(|l| l == "version=1") {
return (
value,
vec!["Unbekannte Optionsversion; Defaults aktiv".into()],
Some(bytes),
);
}
for line in text
.lines()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
{
let Some((key, s)) = line.split_once('=') else {
errors.push(format!("Ungültige Optionszeile: {line}"));
continue;
};
let mut candidate = value.clone();
let result = (|| -> Result<()> {
match key {
"version" => ensure!(s == "1", "Unbekannte Version"),
"desktop" => {
let mut chars = s.chars();
candidate.desktop = chars
.next()
.ok_or_else(|| anyhow!("Desktopzeichen fehlt"))?;
ensure!(chars.next().is_none(), "Ein Desktopzeichen erforderlich");
}
"tab_width" => candidate.tab_width = s.parse()?,
"right_help" => candidate.right_help = s.parse()?,
"syntax_checking" => candidate.syntax_checking = s.parse()?,
"source_dir" => candidate.source_dir = s.into(),
"output_dir" => candidate.output_dir = s.into(),
"include_paths" => {
candidate.include_paths = s
.split('|')
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect()
}
_ if key.starts_with("color.") => {
let i: usize = key[6..].parse()?;
ensure!(i < 7, "Unbekanntes Farbelement");
let (a, b) = s.split_once(',').ok_or_else(|| anyhow!("Farbpaar fehlt"))?;
candidate.colors[i] = (a.parse()?, b.parse()?);
}
_ => {
errors.push(format!("Unbekannte Option {key} ignoriert"));
return Ok(());
}
}
candidate.validate()
})();
match result {
Ok(()) => value = candidate,
Err(e) => errors.push(format!("Option {key}: {e}; Vorgabe beibehalten")),
}
}
(value, errors, Some(bytes))
}
pub fn save(&self, path: &Path, expected: Option<&[u8]>) -> Result<Vec<u8>> {
self.validate()?;
let mut text=format!("version=1\ndesktop={}\ntab_width={}\nright_help={}\nsyntax_checking={}\nsource_dir={}\noutput_dir={}\ninclude_paths={}\n",self.desktop,self.tab_width,self.right_help,self.syntax_checking,self.source_dir.display(),self.output_dir.display(),self.include_paths.iter().map(|p|p.to_str().unwrap()).collect::<Vec<_>>().join("|"));
for (i, (fg, bg)) in self.colors.iter().enumerate() {
text.push_str(&format!("color.{i}={fg},{bg}\n"));
}
fs::create_dir_all(path.parent().ok_or_else(|| anyhow!("Optionspfad fehlt"))?)
.context("Optionsverzeichnis erstellen")?;
crate::documents::atomic_write(path, text.as_bytes(), expected, false)?;
Ok(text.into_bytes())
}
}

569
crates/tb-ide/src/render.rs Normal file
View File

@@ -0,0 +1,569 @@
use crate::{
app::{App, DialogKind, FieldValue, Hit, Mode, WindowKind, WindowState},
commands::Command,
};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
Frame,
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub fn dos(n: u8) -> Color {
[
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.min(15) as usize]
}
fn style(app: &App, element: usize) -> Style {
let (fg, bg) = app.options.colors[element];
Style::default().fg(dos(fg)).bg(dos(bg))
}
fn put(f: &mut Frame, area: Rect, text: impl Into<String>, style: Style) {
f.render_widget(Paragraph::new(text.into()).style(style), area);
}
pub fn expanded(text: &str, tab: usize) -> String {
let mut out = String::new();
let mut column = 0;
for c in text.chars() {
if c == '\t' {
let n = tab - column % tab;
out.extend(std::iter::repeat_n(' ', n));
column += n;
} else {
out.push(c);
column += c.width().unwrap_or(0);
}
}
out
}
pub const STATUS: [(&str, Command); 5] = [
("<Shift+F1=Help>", Command::UsingHelp),
("<F6=Window>", Command::NextWindow),
("<F2=Code>", Command::Code),
("<F5=Run>", Command::Continue),
("<F8=Step>", Command::Step),
];
impl App {
pub fn render(&mut self, f: &mut Frame) {
let area = f.area();
self.size = (area.width, area.height);
self.hits.clear();
if area.width < 80 || area.height < 25 {
f.render_widget(Clear, area);
put(
f,
area,
"Terminal Basic benötigt mindestens 80×25 Zellen.",
Style::default(),
);
return;
}
let fill = self.options.desktop.to_string().repeat(area.width as usize);
for row in 0..area.height {
put(
f,
Rect::new(0, row, area.width, 1),
fill.clone(),
style(self, 1),
);
}
let mut windows = self.windows.clone();
windows.sort_by_key(|w| w.id == self.active);
for w in windows {
let rect = self.rect(&w);
if rect.width < 2 || rect.height < 2 {
continue;
}
let active = w.id == self.active;
let content_style = style(
self,
if matches!(w.kind, WindowKind::Code(_)) {
2
} else {
5
},
);
f.render_widget(
Block::default()
.borders(Borders::ALL)
.style(content_style)
.border_style(style(self, 4)),
rect,
);
self.hits.push((rect, Hit::Window(w.id)));
let title_style = style(self, if active { 3 } else { 4 });
put(
f,
Rect::new(rect.x, rect.y, rect.width, 1),
format!("[≡] {}", self.title(&w)),
title_style,
);
if active {
self.hits.push((
Rect::new(rect.x, rect.y, 3, 1),
Hit::Command(Command::ControlMenu),
));
}
if rect.width > 10 {
let x = rect.right() - 6;
put(f, Rect::new(x, rect.y, 6, 1), "[_][↑]", title_style);
if active {
self.hits
.push((Rect::new(x, rect.y, 3, 1), Hit::Command(Command::Minimize)));
self.hits.push((
Rect::new(x + 3, rect.y, 3, 1),
Hit::Command(Command::Maximize),
));
}
}
if w.state == WindowState::Minimized {
continue;
}
let inner = Rect::new(rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2);
match w.kind {
WindowKind::Code(view) => {
let v = self.project.view(view).unwrap();
let doc = self.project.document(v.document()).unwrap();
if self.mode == Mode::Designer
&& active
&& matches!(doc.content(), tb_vm::project_io::Content::Form(_))
{
put(
f,
inner,
"Formular · Designerwerkzeuge folgen in Change 05",
content_style,
);
} else {
let lines: Vec<_> = doc
.code()
.split('\n')
.map(|line| expanded(line, self.options.tab_width))
.collect();
let count = lines.len();
let longest = lines.iter().map(|s| s.width()).max().unwrap_or(0);
let text = lines
.into_iter()
.skip(v.scroll_line)
.map(|s| Line::raw(s.chars().skip(v.scroll_column).collect::<String>()))
.collect::<Vec<_>>();
f.render_widget(Paragraph::new(text).style(content_style), inner);
if count > inner.height as usize {
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.style(style(self, 4)),
rect,
&mut ScrollbarState::new(count)
.position(v.scroll_line)
.viewport_content_length(inner.height as usize),
);
}
if longest > inner.width as usize {
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
.style(style(self, 4)),
rect,
&mut ScrollbarState::new(longest)
.position(v.scroll_column)
.viewport_content_length(inner.width as usize),
);
}
if active && self.dialog.is_none() && self.menu.is_none() {
let before = &doc.code()[..v.cursor.min(doc.code().len())];
let row = before.bytes().filter(|c| *c == b'\n').count();
let col = expanded(
before.rsplit('\n').next().unwrap_or(""),
self.options.tab_width,
)
.width();
if row >= v.scroll_line
&& row - v.scroll_line < inner.height as usize
&& col >= v.scroll_column
&& col - v.scroll_column < inner.width as usize
{
f.set_cursor_position((
inner.x + (col - v.scroll_column) as u16,
inner.y + (row - v.scroll_line) as u16,
));
}
}
}
}
WindowKind::Project => {
let button_width = 9.min(inner.width / 2);
for (i, (text, c)) in [("Form", Command::Form), ("Code", Command::Code)]
.into_iter()
.enumerate()
{
let r = Rect::new(
inner.x + i as u16 * button_width,
inner.y,
button_width,
3.min(inner.height),
);
f.render_widget(
Paragraph::new(text)
.block(
Block::bordered()
.border_type(ratatui::widgets::BorderType::Double)
.border_style(if active {
Style::default().fg(Color::White)
} else {
style(self, 4)
}),
)
.style(content_style),
r,
);
self.hits.push((r, Hit::ProjectView(c == Command::Form)));
}
let members = self.project.members();
let visible = inner.height.saturating_sub(3) as usize;
let start = self
.selected_member
.saturating_sub(visible.saturating_sub(1));
for (row, id) in members.iter().enumerate().skip(start).take(visible) {
let r =
Rect::new(inner.x, inner.y + 3 + (row - start) as u16, inner.width, 1);
let doc = self.project.document(*id).unwrap();
let text = format!(
"{}{}",
if self.project.startup() == Some(*id) {
""
} else {
" "
},
doc.source_path()
.file_name()
.unwrap_or_default()
.to_string_lossy()
);
put(
f,
r,
text,
if row == self.selected_member {
Style::default().fg(Color::White).bg(Color::Black)
} else {
content_style
},
);
self.hits.push((r, Hit::ProjectMember(row)));
}
}
kind => put(
f,
inner,
match kind {
WindowKind::Output => "Output · Ausführung folgt in Change 04",
WindowKind::Help => "Help · Inhalte folgen in Change 07",
_ => "Debugger-Inhalte folgen in Change 06",
},
content_style,
),
}
}
if self.mode == Mode::Environment {
let row = area.height - 1;
put(
f,
Rect::new(0, row, area.width, 1),
" ".repeat(area.width as usize),
style(self, 6),
);
let mut x = 0;
for (label, c) in STATUS {
let width = label.len() as u16;
if x + width > area.width.saturating_sub(11) {
break;
}
put(f, Rect::new(x, row, width, 1), label, style(self, 6));
self.hits
.push((Rect::new(x, row, width, 1), Hit::Command(c)));
x += width + 1;
}
let pos = self
.active_window()
.and_then(|w| {
if let WindowKind::Code(v) = w.kind {
self.project.view(v).ok()
} else {
None
}
})
.map(|v| {
let code = self.project.document(v.document()).unwrap().code();
let before = &code[..v.cursor.min(code.len())];
format!(
"{:05}:{:03}",
before.bytes().filter(|c| *c == b'\n').count() + 1,
expanded(
before.rsplit('\n').next().unwrap_or(""),
self.options.tab_width
)
.width()
+ 1
)
})
.unwrap_or_else(|| "00001:001".into());
put(f, Rect::new(area.width - 9, row, 9, 1), pos, style(self, 6));
put(
f,
Rect::new(0, row - 1, area.width, 1),
self.message.clone(),
style(self, 1),
);
}
if self.mode == Mode::Designer && self.properties {
put(
f,
Rect::new(0, 0, area.width, 1),
format!(
"Property: [Caption]↓ Value: […]↓ │ Spalte,Zeile │ BxH{}",
if self.value_focus {
" · Value aktiv"
} else {
""
}
),
style(self, 0),
);
self.hits.push((
Rect::new(0, 0, area.width, 1),
Hit::Command(Command::MenuBar),
));
} else {
put(
f,
Rect::new(0, 0, area.width, 1),
" ".repeat(area.width as usize),
style(self, 0),
);
let menus = self.menus();
let mut x = 0;
for (i, m) in menus.iter().enumerate() {
if m.title == "Help" {
x = area.width - 6;
}
let r = Rect::new(x, 0, m.title.len() as u16 + 2, 1);
let spans = vec![
Span::raw(" "),
Span::styled(
&m.title[..1],
style(self, 0).add_modifier(Modifier::UNDERLINED),
),
Span::raw(format!("{} ", &m.title[1..])),
];
f.render_widget(
Paragraph::new(Line::from(spans)).style(
if self.menu.is_some_and(|(n, _)| n == i) {
Style::default().fg(Color::White).bg(Color::Black)
} else {
style(self, 0)
},
),
r,
);
self.hits.push((r, Hit::Menu(i)));
x += r.width;
}
}
if let Some((menu, selected)) = self.menu {
let entries = self.menu_entries(menu);
let width = (entries.iter().map(|(s, _)| s.width()).max().unwrap_or(12) as u16 + 4)
.min(area.width);
let menus = self.menus();
let x = if self.control_menu {
self.active_window().map(|w| self.rect(w).x).unwrap_or(0)
} else if menus[menu].title == "Help" {
area.width - width
} else {
menus
.iter()
.take(menu)
.map(|m| m.title.len() as u16 + 2)
.sum::<u16>()
.min(area.width - width)
};
let visible = (area.height - 4) as usize;
let start = selected.saturating_sub(visible - 1);
let rect = Rect::new(x, 1, width, (entries.len().min(visible) + 2) as u16);
f.render_widget(Clear, rect);
f.render_widget(Block::bordered().style(style(self, 0)), rect);
for (i, (text, c)) in entries.iter().enumerate().skip(start).take(visible) {
let disabled = c.and_then(|c| self.availability(c));
let st = if disabled.is_some() {
Style::default().fg(Color::DarkGray).bg(Color::Gray)
} else if i == selected {
Style::default().fg(Color::White).bg(Color::Black)
} else {
style(self, 0)
};
let r = Rect::new(x + 1, 2 + (i - start) as u16, width - 2, 1);
let label = if *c == Some(Command::SyntaxChecking) && self.options.syntax_checking {
format!("{text}")
} else {
text.clone()
};
let mut spans = Vec::new();
let mut underline = false;
for c in label.chars() {
if c == '&' {
underline = true;
continue;
}
spans.push(Span::styled(
c.to_string(),
if underline {
st.add_modifier(Modifier::UNDERLINED)
} else {
st
},
));
underline = false;
}
f.render_widget(Paragraph::new(Line::from(spans)).style(st), r);
self.hits.push((r, Hit::MenuItem(i)));
}
if let Some((_, Some(c))) = entries.get(selected) {
if let Some(reason) = self.availability(*c) {
put(
f,
Rect::new(0, area.height - 2, area.width, 1),
reason,
style(self, 0),
);
}
}
}
if let Some(d) = &self.dialog {
let width = 76.min(area.width - 2);
let height = (d.fields.len() as u16 + 8).min(area.height - 2);
let rect = Rect::new(
(area.width - width) / 2,
(area.height - height) / 2,
width,
height,
);
let st = style(self, 5);
f.render_widget(Clear, rect);
f.render_widget(Block::bordered().title(d.title.clone()).style(st), rect);
let visible = height.saturating_sub(7) as usize;
let start = d
.focus
.min(d.fields.len().saturating_sub(1))
.saturating_sub(visible.saturating_sub(1));
for (i, field) in d.fields.iter().enumerate().skip(start).take(visible) {
let r = Rect::new(rect.x + 2, rect.y + 1 + (i - start) as u16, width - 4, 1);
let value = match &field.value {
FieldValue::Choice { .. } => format!("{}", field.string()),
FieldValue::Toggle(v) => {
if *v {
"[x]".into()
} else {
"[ ]".into()
}
}
_ => field.string(),
};
let label = format!("{}: {}", field.label, value);
let selected = d.focus == i;
let field_style = if selected {
Style::default().fg(Color::White).bg(Color::Black)
} else {
st
};
let caret = if let FieldValue::Text(text) = &field.value {
field.label.width() + 2 + text[..field.cursor].width()
} else {
0
};
let scroll = if selected {
caret.saturating_sub(r.width.saturating_sub(1) as usize)
} else {
0
};
f.render_widget(
Paragraph::new(label)
.style(field_style)
.scroll((0, scroll.min(u16::MAX as usize) as u16)),
r,
);
if selected
&& matches!(field.value, FieldValue::Text(_))
&& !(matches!(d.kind, DialogKind::Export(_)) && i == 0)
{
f.set_cursor_position((r.x + (caret - scroll) as u16, r.y));
}
self.hits.push((r, Hit::DialogField(i)));
}
let export = matches!(d.kind, DialogKind::Export(_));
let submit = Rect::new(rect.x + 2, rect.bottom() - 3, 16, 1);
let cancel = Rect::new(rect.x + 20, rect.bottom() - 3, 14, 1);
put(
f,
submit,
if export { "[Prüfen]" } else { "[OK / Enter]" },
if d.focus == d.fields.len() {
Style::default().fg(Color::White).bg(Color::Black)
} else {
st
},
);
put(f, cancel, "[Abbrechen]", st);
self.hits.push((submit, Hit::DialogSubmit));
self.hits.push((cancel, Hit::DialogCancel));
if export {
put(
f,
Rect::new(rect.x + 36, rect.bottom() - 3, width - 38, 1),
"[Erzeugen: Phase 6]",
Style::default().fg(Color::DarkGray).bg(Color::Gray),
);
}
let mut status = d.error.clone();
if export {
if let Some(request) = &d.request {
status = format!(
"{}: {} · {}",
d.export_status.text(),
request.path.display(),
d.error
);
} else if status.is_empty() {
status = crate::export::UNAVAILABLE.into();
}
}
f.render_widget(
Paragraph::new(status)
.wrap(ratatui::widgets::Wrap { trim: false })
.style(st),
Rect::new(rect.x + 2, rect.bottom() - 6, width - 4, 3),
);
put(
f,
Rect::new(rect.x + 2, rect.bottom() - 2, width - 4, 1),
"Tab: Feld · ◄/►: Auswahl · F2: Dateiwahl · Esc: Abbrechen",
st,
);
}
}
}

View File

@@ -0,0 +1,102 @@
use crossterm::{
cursor::{Hide, Show},
event::{DisableMouseCapture, EnableMouseCapture},
execute,
style::ResetColor,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::io::{self, Write};
/// Ein Besitzer für Raw Mode und Terminalzustand, auch bei teilweiser Initialisierung.
pub struct TerminalGuard<W: Write> {
writer: W,
raw: fn(bool) -> io::Result<()>,
}
impl<W: Write> TerminalGuard<W> {
pub fn enter(writer: W) -> io::Result<Self> {
Self::with_raw(writer, |on| {
if on {
enable_raw_mode()
} else {
disable_raw_mode()
}
})
}
fn with_raw(writer: W, raw: fn(bool) -> io::Result<()>) -> io::Result<Self> {
let mut guard = Self { writer, raw };
(guard.raw)(true)?;
execute!(guard.writer, EnterAlternateScreen, EnableMouseCapture, Hide)?;
Ok(guard)
}
}
impl<W: Write> Drop for TerminalGuard<W> {
fn drop(&mut self) {
let _ = (self.raw)(false);
let _ = execute!(
self.writer,
ResetColor,
Show,
DisableMouseCapture,
LeaveAlternateScreen
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
cell::RefCell,
rc::Rc,
sync::atomic::{AtomicI32, Ordering},
};
static RAW: AtomicI32 = AtomicI32::new(0);
#[derive(Clone)]
struct Output(Rc<RefCell<Vec<u8>>>);
impl Write for Output {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.0.borrow_mut().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn raw(on: bool) -> io::Result<()> {
RAW.store(i32::from(on), Ordering::SeqCst);
Ok(())
}
#[test]
fn terminal_cleanup_on_normal_error_and_partial_initialization() {
for fail in [false, true] {
let out = Output(Rc::default());
let copy = out.clone();
let result = (|| -> io::Result<()> {
let _guard = TerminalGuard::with_raw(out, raw)?;
if fail {
return Err(io::Error::other("Renderfehler"));
}
Ok(())
})();
assert_eq!(result.is_err(), fail);
assert_eq!(RAW.load(Ordering::SeqCst), 0);
let s = String::from_utf8(copy.0.borrow().clone()).unwrap();
assert!(s.contains("?1049h") && s.contains("?1049l") && s.contains("?25h"));
}
let out = Output(Rc::default());
let copy = out.clone();
assert!(TerminalGuard::with_raw(out, |on| {
raw(on)?;
if on {
Err(io::Error::other("Raw-Fehler"))
} else {
Ok(())
}
})
.is_err());
assert_eq!(RAW.load(Ordering::SeqCst), 0);
assert!(String::from_utf8(copy.0.borrow().clone())
.unwrap()
.contains("?25h"));
}
}

693
crates/tb-ide/tests/app.rs Normal file
View File

@@ -0,0 +1,693 @@
use crossterm::event::{
Event, KeyCode as K, KeyEvent, KeyModifiers as M, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{backend::TestBackend, style::Color, Terminal};
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
};
use tb_ide::{
app::{App, DialogKind, Execution, Mode, WindowKind, WindowState},
commands::{self, Command},
export::{ExportStatus, ProjectStamp},
options::Options,
};
struct Temp(PathBuf);
impl Temp {
fn new() -> Self {
static N: AtomicUsize = AtomicUsize::new(0);
let path = std::env::temp_dir().join(format!(
"tb-app-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
Self(path.canonicalize().unwrap())
}
fn app(&self) -> App {
App::new(&self.0, self.0.join("config/options.ini"), (100, 30)).unwrap()
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn key(app: &mut App, k: K, m: M) {
app.handle(Event::Key(KeyEvent::new(k, m)));
}
fn plain(app: &mut App, k: K) {
key(app, k, M::NONE);
}
fn type_text(app: &mut App, text: &str) {
for c in text.chars() {
plain(app, K::Char(c));
}
}
fn field(app: &mut App, index: usize, text: &str) {
while app.dialog.as_ref().unwrap().focus != index {
plain(app, K::Tab);
}
key(app, K::Char('a'), M::CONTROL);
plain(app, K::Delete);
type_text(app, text);
}
fn menu(app: &mut App, command: Command) {
let menus = app.menus();
let (m, i) = menus
.iter()
.enumerate()
.find_map(|(m, menu)| {
menu.items
.iter()
.position(|i| i.command == Some(command))
.map(|i| (m, i))
})
.unwrap();
key(app, K::Char(menus[m].mnemonic), M::ALT);
for _ in 0..i {
plain(app, K::Down);
}
plain(app, K::Enter);
assert_eq!(app.last_command, Some(command));
}
fn draw(app: &mut App) -> (String, ratatui::buffer::Buffer) {
let mut term = Terminal::new(TestBackend::new(app.size.0, app.size.1)).unwrap();
term.draw(|f| app.render(f)).unwrap();
let b = term.backend().buffer().clone();
let text = b
.content
.chunks(app.size.0 as usize)
.map(|row| row.iter().map(|c| c.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
(text, b)
}
fn click(app: &mut App, x: u16, y: u16) {
app.handle(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: M::NONE,
}));
}
#[test]
fn start_snapshot_and_reference_menus_have_all_commands() {
let t = Temp::new();
let mut app = t.app();
let (text, b) = draw(&mut app);
assert!(
text.contains("[1] Untitled")
&& text.contains("Project: Untitled")
&& text.contains("Copyright")
&& text.contains("00001:001")
);
assert_eq!(b[(4, 1)].bg, Color::Magenta);
assert_eq!(b[(4, 1)].fg, Color::White);
assert_eq!(b[(4, 2)].bg, Color::Blue);
assert_eq!(b[(1, 29)].bg, Color::Cyan);
assert_eq!(b[(1, 0)].bg, Color::Gray);
plain(&mut app, K::Char('x'));
assert_eq!(
app.project
.document(app.active_document().unwrap())
.unwrap()
.code(),
"x"
);
assert_eq!(
commands::menus(false)
.iter()
.map(|m| m.title)
.collect::<Vec<_>>(),
["File", "Edit", "View", "Search", "Run", "Debug", "Options", "Window", "Help"]
);
assert_eq!(
commands::menus(true)
.iter()
.map(|m| m.title)
.collect::<Vec<_>>(),
["File", "Edit", "View", "Tools", "Options", "Window", "Help"]
);
let reference = include_str!("../../../docs/ide-referenz.md");
for designer in [false, true] {
for m in commands::menus(designer) {
let mut mnemonics = std::collections::BTreeSet::new();
for item in m.items.iter().filter(|i| i.command.is_some()) {
let name = item.text();
assert!(
reference.contains(name.trim_end_matches('…')),
"Nicht in Referenz: {name}"
);
assert!(item.mnemonic().is_some());
assert!(
mnemonics.insert(item.mnemonic().unwrap()),
"Doppeltes Mnemonic in {}: {}",
m.title,
item.label
);
}
}
}
assert_eq!(
commands::menus(false)[0]
.items
.iter()
.filter_map(|i| i.command)
.collect::<Vec<_>>(),
[
Command::NewProject,
Command::OpenProject,
Command::SaveProject,
Command::NewForm,
Command::NewModule,
Command::AddFile,
Command::RemoveFile,
Command::SaveFile,
Command::SaveAs,
Command::LoadText,
Command::SaveText,
Command::Print,
Command::Shell,
Command::Exit
]
);
}
#[test]
fn modal_focus_and_dirty_project_cancel_preserve_document() {
let t = Temp::new();
let mut app = t.app();
type_text(&mut app, "PRINT 7");
let id = app.active_document().unwrap();
let active = app.active;
menu(&mut app, Command::NewModule);
type_text(&mut app, "Other");
plain(&mut app, K::F(6));
key(&mut app, K::Char('c'), M::CONTROL);
plain(&mut app, K::Esc);
assert_eq!(app.active, active);
assert_eq!(app.project.document(id).unwrap().code(), "PRINT 7");
assert!(app.basic_events.is_empty());
key(&mut app, K::F(4), M::ALT);
assert!(matches!(
app.dialog.as_ref().unwrap().kind,
DialogKind::Dirty(_)
));
plain(&mut app, K::Enter);
assert!(!app.quit);
menu(&mut app, Command::NewProject);
plain(&mut app, K::Right);
plain(&mut app, K::Enter);
assert!(matches!(
app.dialog.as_ref().unwrap().kind,
DialogKind::Save { .. }
));
plain(&mut app, K::Esc);
assert!(app.project.document(id).unwrap().is_dirty());
assert!(!app.quit);
menu(&mut app, Command::SaveProject);
field(&mut app, 0, &t.0.join("project.mak").display().to_string());
plain(&mut app, K::Enter);
assert!(app.dialog.is_none(), "{:?}", app.dialog);
assert!(!app.project.is_dirty());
menu(&mut app, Command::Exit);
assert!(app.quit);
}
#[test]
fn dispatcher_keeps_function_keys_and_copy_out_of_basic_input() {
let t = Temp::new();
let mut app = t.app();
key(&mut app, K::Char('c'), M::CONTROL);
assert_eq!(app.last_command, Some(Command::Copy));
assert!(app.message.contains("03"));
plain(&mut app, K::F(11));
assert!(app.menu.is_some());
plain(&mut app, K::Esc);
menu(&mut app, Command::NewForm);
let form = app.active_document();
assert_eq!(app.mode, Mode::Designer);
plain(&mut app, K::F(2));
assert!(app.value_focus);
plain(&mut app, K::F(10));
assert!(!app.properties && app.menu.is_some());
plain(&mut app, K::F(10));
assert!(app.properties && app.menu.is_none());
plain(&mut app, K::F(12));
assert_eq!(app.last_command, Some(Command::Events));
assert_eq!(app.active_document(), form);
assert!(app.basic_events.is_empty());
plain(&mut app, K::F(11));
plain(&mut app, K::Esc);
menu(&mut app, Command::Code);
plain(&mut app, K::Enter);
assert_eq!(app.mode, Mode::Environment);
app.execution = Execution::Paused;
plain(&mut app, K::F(10));
assert_eq!(app.last_command, Some(Command::ProcedureStep));
assert!(app.message.contains("06"));
menu(&mut app, Command::Output);
app.execution = Execution::Running;
plain(&mut app, K::Char('a'));
assert_eq!(app.basic_events.len(), 1);
plain(&mut app, K::F(11));
assert_eq!(app.basic_events.len(), 1);
}
#[test]
fn windows_restore_geometry_and_resize_without_document_loss() {
let t = Temp::new();
let mut app = t.app();
type_text(&mut app, "abc");
let doc = app.active_document().unwrap();
let first = app.active;
menu(&mut app, Command::NewWindow);
assert_eq!(app.active_document(), Some(doc));
assert_eq!(app.windows.len(), 3);
key(&mut app, K::F(8), M::CONTROL);
plain(&mut app, K::Left);
plain(&mut app, K::Up);
plain(&mut app, K::Enter);
key(&mut app, K::F(7), M::CONTROL);
plain(&mut app, K::Right);
plain(&mut app, K::Down);
plain(&mut app, K::Enter);
let normal = app.active_window().unwrap().normal;
key(&mut app, K::F(9), M::CONTROL);
assert_eq!(app.active_window().unwrap().state, WindowState::Minimized);
key(&mut app, K::F(10), M::CONTROL);
assert_eq!(app.active_window().unwrap().state, WindowState::Maximized);
key(&mut app, K::F(5), M::CONTROL);
assert_eq!(app.active_window().unwrap().normal, normal);
let active = app.active;
app.handle(Event::Resize(40, 12));
let (small, _) = draw(&mut app);
assert!(small.contains("mindestens"));
assert!(!small.contains("Untitled"));
plain(&mut app, K::Char('z'));
app.handle(Event::Resize(80, 25));
draw(&mut app);
assert_eq!(app.active, active);
assert_eq!(app.project.document(doc).unwrap().code(), "abc");
for w in &app.windows {
let r = app.rect(w);
assert!(r.right() <= 80 && r.bottom() <= 24);
}
key(&mut app, K::Char('-'), M::ALT);
assert!(app.control_menu);
plain(&mut app, K::Esc);
key(&mut app, K::F(4), M::CONTROL);
assert_eq!(app.windows.len(), 2);
assert!(app.project.document(doc).is_ok());
assert_eq!(app.active, first);
plain(&mut app, K::F(6));
assert_ne!(app.active, first);
key(&mut app, K::F(6), M::SHIFT);
assert_eq!(app.active, first);
menu(&mut app, Command::Arrange);
assert_eq!(app.windows[0].normal.right(), app.windows[1].normal.x);
}
#[test]
fn status_and_project_buttons_use_the_keyboard_command_path() {
let t = Temp::new();
let mut app = t.app();
draw(&mut app);
let before = app.active;
click(&mut app, 17, 29);
assert_eq!(app.last_command, Some(Command::NextWindow));
assert_ne!(app.active, before);
let project = app
.windows
.iter()
.find(|w| w.kind == WindowKind::Project)
.unwrap()
.clone();
let r = app.rect(&project);
draw(&mut app);
click(&mut app, r.x + 12, r.y + 2);
assert_eq!(app.last_command, Some(Command::Code));
plain(&mut app, K::Esc);
menu(&mut app, Command::NewForm);
draw(&mut app);
let (text, _) = draw(&mut app);
assert!(text.contains("Property:"));
assert!(!text.contains("<F6=Window>"));
}
#[test]
fn export_dialogs_validate_and_render_all_results_without_creating_files() {
let t = Temp::new();
let mut app = t.app();
let stamp = ProjectStamp::capture(&app.project);
for command in [Command::MakeExe, Command::MakeLibrary] {
menu(&mut app, command);
while app.dialog.as_ref().unwrap().focus != 1 {
plain(&mut app, K::Tab);
}
plain(&mut app, K::Right);
let system = app.dialog.as_ref().unwrap().fields[1].string();
plain(&mut app, K::Tab);
plain(&mut app, K::Right);
let architecture = app.dialog.as_ref().unwrap().fields[2].string();
let (text, _) = draw(&mut app);
assert!(text.contains("Erzeugen: Phase 6"));
field(&mut app, 3, "");
plain(&mut app, K::Enter);
assert!(app.dialog.as_ref().unwrap().error.contains("Ausgabepfad"));
field(&mut app, 3, &t.0.join("notthere/out").display().to_string());
plain(&mut app, K::Enter);
assert!(app
.dialog
.as_ref()
.unwrap()
.error
.contains("Zielverzeichnis"));
field(&mut app, 3, &t.0.join("bad.tbc").display().to_string());
plain(&mut app, K::Enter);
assert!(app.dialog.as_ref().unwrap().error.contains("TBC"));
let existing = t.0.join("existing");
fs::write(&existing, b"keep").unwrap();
field(&mut app, 3, &existing.display().to_string());
plain(&mut app, K::Enter);
assert!(app
.dialog
.as_ref()
.unwrap()
.error
.contains("Überschreibentscheidung"));
plain(&mut app, K::Tab);
plain(&mut app, K::Char(' '));
plain(&mut app, K::Enter);
assert_eq!(fs::read(&existing).unwrap(), b"keep");
assert!(app.dialog.as_ref().unwrap().request.is_some());
let target = t.0.join("result");
field(&mut app, 3, &target.display().to_string());
plain(&mut app, K::Enter);
let request = app.dialog.as_ref().unwrap().request.clone().unwrap();
assert_eq!(request.system, system);
assert_eq!(request.architecture, architecture);
assert_eq!(request.project, stamp);
for status in [
ExportStatus::Running,
ExportStatus::Success,
ExportStatus::Failed("Testfehler".into()),
ExportStatus::Cancelled,
] {
app.export_result(&request, status.clone()).unwrap();
let (text, _) = draw(&mut app);
assert!(text.contains(&status.text()), "{text}");
assert!(!target.exists());
}
plain(&mut app, K::Esc);
assert_eq!(ProjectStamp::capture(&app.project), stamp);
}
}
#[test]
fn file_picker_save_conflict_and_project_replacement_use_real_events() {
let t = Temp::new();
let mut app = t.app();
let member = t.0.join("existing.bas");
fs::write(&member, "PRINT 2\n").unwrap();
menu(&mut app, Command::AddFile);
plain(&mut app, K::F(2));
let DialogKind::Browse { entries, .. } = &app.dialog.as_ref().unwrap().kind else {
panic!("Dateiwahl fehlt")
};
let index = entries.iter().position(|p| p == &member).unwrap();
for _ in 0..index {
plain(&mut app, K::Right);
}
plain(&mut app, K::Enter);
assert!(matches!(
app.dialog.as_ref().unwrap().kind,
DialogKind::AddFile
));
plain(&mut app, K::Enter);
let id = app.active_document().unwrap();
type_text(&mut app, "'edit\n");
fs::write(&member, "external\n").unwrap();
menu(&mut app, Command::SaveFile);
plain(&mut app, K::Enter);
assert!(app
.dialog
.as_ref()
.unwrap()
.error
.contains("Überschreibentscheidung"));
assert_eq!(fs::read_to_string(&member).unwrap(), "external\n");
plain(&mut app, K::Tab);
plain(&mut app, K::Char(' '));
plain(&mut app, K::Enter);
assert!(app.dialog.is_none());
assert!(!app.project.document(id).unwrap().is_dirty());
menu(&mut app, Command::LoadText);
plain(&mut app, K::F(2));
plain(&mut app, K::Esc);
assert!(matches!(
app.dialog.as_ref().unwrap().kind,
DialogKind::LoadText
));
plain(&mut app, K::Esc);
let path = t.0.join("sub/p.mak");
fs::create_dir(path.parent().unwrap()).unwrap();
fs::write(&path, "main.bas\n").unwrap();
fs::write(path.parent().unwrap().join("main.bas"), "END\n").unwrap();
menu(&mut app, Command::OpenProject);
field(&mut app, 0, &path.display().to_string());
plain(&mut app, K::Enter);
assert!(matches!(
app.dialog.as_ref().unwrap().kind,
DialogKind::Dirty(_)
));
plain(&mut app, K::Right);
plain(&mut app, K::Right);
plain(&mut app, K::Enter);
assert!(app.dialog.is_none());
assert_eq!(app.project.path(), Some(path.as_path()));
menu(&mut app, Command::NewProject);
assert!(app.dialog.is_none());
assert_eq!(app.project.members().len(), 1);
assert_eq!(app.project.directory(), path.parent().unwrap());
}
#[test]
fn exit_with_partial_save_retains_dialog_and_unsaved_documents() {
let t = Temp::new();
fs::write(t.0.join("a.bas"), "END\n").unwrap();
fs::write(t.0.join("b.bas"), "END\n").unwrap();
let mak = t.0.join("p.mak");
fs::write(&mak, "a.bas\nb.bas\n").unwrap();
let mut app = t.app();
app.load_initial_project(mak.clone()).unwrap();
let ids = app.project.members();
for id in &ids {
app.project.replace_text(*id, 0..0, "' changed\n").unwrap();
}
let b = t.0.join("b.bas");
let original = fs::metadata(&b).unwrap().permissions();
let mut perms = original.clone();
perms.set_readonly(true);
fs::set_permissions(&b, perms).unwrap();
key(&mut app, K::F(4), M::ALT);
plain(&mut app, K::Right);
plain(&mut app, K::Enter);
plain(&mut app, K::Enter);
assert!(app
.dialog
.as_ref()
.unwrap()
.error
.contains("schreibgeschützt"));
assert!(!app.quit);
assert!(!app.project.document(ids[0]).unwrap().is_dirty());
assert!(app.project.document(ids[1]).unwrap().is_dirty());
assert_eq!(fs::read_to_string(&mak).unwrap(), "a.bas\nb.bas\n");
fs::set_permissions(&b, original).unwrap();
plain(&mut app, K::Enter);
assert!(app.quit);
assert!(!app.project.is_dirty());
}
#[test]
fn mouse_focus_is_modal_and_program_abort_is_not_editor_copy() {
let t = Temp::new();
let mut app = t.app();
let active = app.active;
menu(&mut app, Command::NewModule);
draw(&mut app);
click(&mut app, 85, 2);
assert_eq!(app.active, active);
plain(&mut app, K::Esc);
menu(&mut app, Command::Output);
app.execution = Execution::Running;
key(&mut app, K::Char('c'), M::CONTROL);
assert_eq!(
app.basic_events,
vec![Event::Key(KeyEvent::new(K::Char('c'), M::CONTROL))]
);
let r = app.rect(app.active_window().unwrap());
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: r.x + 2,
row: r.y + 2,
modifiers: M::NONE,
};
app.handle(Event::Mouse(mouse));
assert_eq!(app.basic_events.last(), Some(&Event::Mouse(mouse)));
app.execution = Execution::Idle;
menu(&mut app, Command::RightMouse);
plain(&mut app, K::Char(' '));
plain(&mut app, K::Enter);
let before = app.last_command;
app.handle(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 2,
row: 2,
modifiers: M::NONE,
}));
assert_eq!(app.last_command, before);
menu(&mut app, Command::RightMouse);
plain(&mut app, K::Char(' '));
plain(&mut app, K::Enter);
app.handle(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 2,
row: 2,
modifiers: M::NONE,
}));
assert_eq!(app.last_command, Some(Command::Topic));
}
#[test]
fn designer_form_list_and_context_availability_match_the_active_window() {
let t = Temp::new();
fs::write(
t.0.join("Form1.frm"),
"VERSION 1.00\nBegin Form Existing\nEnd\n",
)
.unwrap();
let mut app = t.app();
menu(&mut app, Command::NewForm);
assert_eq!(
app.project
.document(app.active_document().unwrap())
.unwrap()
.source_path()
.file_name()
.unwrap(),
"Form2.frm"
);
let entries = app.window_commands();
assert_eq!(entries.len(), 1);
assert!(entries[0].0.starts_with("• Form:"));
plain(&mut app, K::F(11));
key(&mut app, K::Char('h'), M::ALT);
assert_eq!(app.menus()[app.menu.unwrap().0].title, "Help");
plain(&mut app, K::Esc);
menu(&mut app, Command::Code);
plain(&mut app, K::Enter);
menu(&mut app, Command::Project);
assert!(app
.availability(Command::LoadText)
.unwrap()
.contains("Codefenster"));
}
#[test]
fn clicking_project_members_and_dialog_choices_transfers_focus_and_applies_values() {
let t = Temp::new();
let mut app = t.app();
menu(&mut app, Command::NewForm);
menu(&mut app, Command::Code);
plain(&mut app, K::Enter);
menu(&mut app, Command::Project);
draw(&mut app);
let r = app.rect(app.active_window().unwrap());
// Zweites Mitglied ist das Formular; der Listenklick bindet Enter an Project.
click(&mut app, r.x + 3, r.y + 5);
assert_eq!(app.active_window().unwrap().kind, WindowKind::Project);
plain(&mut app, K::Enter);
assert_eq!(app.mode, Mode::Designer);
menu(&mut app, Command::RightMouse);
draw(&mut app);
let hit = app
.hits
.iter()
.find(|(_, h)| matches!(h, tb_ide::app::Hit::DialogField(0)))
.unwrap()
.0;
click(&mut app, hit.x, hit.y);
plain(&mut app, K::Enter);
assert!(!app.options.right_help);
}
#[test]
fn options_apply_persist_across_bases_and_preserve_unsaved_values_on_error() {
let t = Temp::new();
let mut app = t.app();
menu(&mut app, Command::Display);
// Titelhintergrund: Palette 5 -> 6, Desktopzeichen und Tabweite.
while app.dialog.as_ref().unwrap().focus != 7 {
plain(&mut app, K::Tab);
}
plain(&mut app, K::Right);
field(&mut app, 14, ".");
field(&mut app, 15, "4");
plain(&mut app, K::Enter);
assert!(app.dialog.is_none());
let (_, b) = draw(&mut app);
assert_eq!(b[(4, 1)].bg, Color::Yellow);
assert_eq!(app.options.tab_width, 4);
let include = t.0.join("includes");
fs::create_dir(&include).unwrap();
fs::write(include.join("shared.bi"), "PRINT 42\n").unwrap();
menu(&mut app, Command::Paths);
field(&mut app, 2, &include.display().to_string());
plain(&mut app, K::Enter);
let id = app.active_document().unwrap();
app.project
.replace_text(id, 0..0, "'$INCLUDE: 'shared.bi'\n")
.unwrap();
assert!(app.project.sources().unwrap().units[0]
.segments
.iter()
.any(|s| s.text == "PRINT 42\n"));
menu(&mut app, Command::SyntaxChecking);
assert!(!app.options.syntax_checking);
menu(&mut app, Command::SaveOptions);
plain(&mut app, K::Enter);
assert!(app.dialog.is_none());
let elsewhere = t.0.join("elsewhere");
fs::create_dir(&elsewhere).unwrap();
let fresh = App::new(&elsewhere, app.config_path.clone(), (80, 25)).unwrap();
assert_eq!(fresh.options, app.options);
let mut perms = fs::metadata(&app.config_path).unwrap().permissions();
let original = perms.clone();
perms.set_readonly(true);
fs::set_permissions(&app.config_path, perms).unwrap();
menu(&mut app, Command::SyntaxChecking);
menu(&mut app, Command::SaveOptions);
plain(&mut app, K::Enter);
assert!(app
.dialog
.as_ref()
.unwrap()
.error
.contains("schreibgeschützt"));
assert_ne!(app.options, app.saved_options);
fs::set_permissions(&app.config_path, original).unwrap();
fs::write(
&app.config_path,
"version=1\ntab_width=0\ncolor.3=999,5\ndesktop=xx\nfuture=foo\n",
)
.unwrap();
let (defaults, errors, _) = Options::load(&app.config_path);
assert_eq!(defaults, Options::default());
assert_eq!(errors.len(), 4);
}