Phase 5: IDE-Rahmen implementieren, synchronisieren und archivieren
This commit is contained in:
@@ -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
1757
crates/tb-ide/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
337
crates/tb-ide/src/commands.rs
Normal file
337
crates/tb-ide/src/commands.rs
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
97
crates/tb-ide/src/export.rs
Normal file
97
crates/tb-ide/src/export.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
187
crates/tb-ide/src/options.rs
Normal file
187
crates/tb-ide/src/options.rs
Normal 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
569
crates/tb-ide/src/render.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
102
crates/tb-ide/src/terminal.rs
Normal file
102
crates/tb-ide/src/terminal.rs
Normal 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
693
crates/tb-ide/tests/app.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user