use crate::{ commands::{self, Command, Menu}, documents::{Decision, Destination, DocumentId, Project, SavePlan, StartupRemoval, ViewId}, export::{Artifact, ExportRequest, ExportStatus}, options::{Options, ELEMENTS}, }; use anyhow::{anyhow, ensure, Result}; use crossterm::event::{ Event, KeyCode as K, KeyEvent, KeyEventKind, KeyModifiers as M, ModifierKeyCode, MouseButton, MouseEventKind, }; use ratatui::layout::Rect; use std::path::{Path, PathBuf}; use tb_vm::project_io::Content; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Environment, Designer, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Execution { Idle, Compiling, Paused, Running, Waiting, Ended, Error, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WindowKind { Code(ViewId), Project, Output, Calls, Debug, Immediate, Help, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WindowState { Normal, Minimized, Maximized, } #[derive(Debug, Clone)] pub struct Window { pub id: u64, pub kind: WindowKind, pub normal: Rect, pub state: WindowState, } #[derive(Debug, Clone)] pub enum FieldValue { Text(String), Choice { items: Vec, selected: usize }, Toggle(bool), } #[derive(Debug, Clone)] pub struct Field { pub label: String, pub value: FieldValue, pub cursor: usize, pub selected: bool, } impl Field { pub fn text(label: impl Into, text: impl Into) -> Self { let text = text.into(); Self { label: label.into(), cursor: text.len(), value: FieldValue::Text(text), selected: true, } } pub fn choice(label: impl Into, items: Vec, selected: usize) -> Self { Self { label: label.into(), value: FieldValue::Choice { selected, items }, cursor: 0, selected: false, } } pub fn toggle(label: impl Into, value: bool) -> Self { Self { label: label.into(), value: FieldValue::Toggle(value), cursor: 0, selected: false, } } pub fn string(&self) -> String { match &self.value { FieldValue::Text(s) => s.clone(), FieldValue::Choice { items, selected } => { items.get(*selected).cloned().unwrap_or_default() } FieldValue::Toggle(v) => v.to_string(), } } pub fn index(&self) -> usize { match self.value { FieldValue::Choice { selected, .. } => selected, _ => 0, } } pub fn flag(&self) -> bool { matches!(self.value, FieldValue::Toggle(true)) } fn key(&mut self, key: KeyEvent) { match &mut self.value { FieldValue::Toggle(value) => { if matches!(key.code, K::Char(' ') | K::Left | K::Right) { *value = !*value } } FieldValue::Choice { items, selected } => { if !items.is_empty() { match key.code { K::Left => *selected = (*selected + items.len() - 1) % items.len(), K::Right | K::Char(' ') => *selected = (*selected + 1) % items.len(), _ => {} } } } FieldValue::Text(s) => { if key.modifiers.contains(M::CONTROL) && key.code == K::Char('a') { self.selected = true; return; } match key.code { K::Char(c) if !key.modifiers.intersects(M::CONTROL | M::ALT) => { if self.selected { s.clear(); self.cursor = 0; } s.insert(self.cursor, c); self.cursor += c.len_utf8(); } K::Backspace => { if self.selected { s.clear(); self.cursor = 0; } else if self.cursor > 0 { let p = s[..self.cursor].char_indices().last().unwrap().0; s.drain(p..self.cursor); self.cursor = p; } } K::Delete => { if self.selected { s.clear(); self.cursor = 0; } else if self.cursor < s.len() { let n = s[self.cursor..].chars().next().unwrap().len_utf8(); s.drain(self.cursor..self.cursor + n); } } K::Left => { self.cursor = s[..self.cursor] .char_indices() .last() .map(|(i, _)| i) .unwrap_or(0); } K::Right => { self.cursor += s[self.cursor..] .chars() .next() .map(char::len_utf8) .unwrap_or(0); } K::Home => self.cursor = 0, K::End => self.cursor = s.len(), _ => return, } self.selected = false; } } } } #[derive(Debug, Clone)] pub enum AfterSave { Stay, Exit, NewProject, OpenProject(PathBuf), } #[derive(Debug, Clone)] pub enum DialogKind { Search { view: ViewId, selection: Option>, }, Procedure(bool), Procedures(Vec), Diagnostics, Browse { return_to: Box, field: usize, entries: Vec, }, Message, OpenProject, NewModule, AddFile, ChooseCode, ChooseForm, Remove, Startup, Save { project: bool, ids: Vec, after: AfterSave, }, Dirty(AfterSave), LoadText, SaveText, Print, CommandLine, ResumeRevision, Display, Paths, RightMouse, SaveOptions, Export(Artifact), } #[derive(Debug, Clone)] pub struct Dialog { pub title: String, pub kind: DialogKind, pub fields: Vec, pub focus: usize, pub error: String, pub request: Option, pub export_status: ExportStatus, } impl Dialog { fn new(title: impl Into, kind: DialogKind, fields: Vec) -> Self { Self { title: title.into(), kind, fields, focus: 0, error: String::new(), request: None, export_status: ExportStatus::Ready, } } } #[derive(Debug, Clone, Copy)] pub enum Hit { Command(Command), Menu(usize), MenuItem(usize), Window(u64), ProjectMember(usize), ProjectView(bool), DialogField(usize), DialogSubmit, DialogCancel, } pub struct App { pub session: crate::execution::Session, pub editor: crate::editor::Editor, pub project: Project, pub options: Options, pub mode: Mode, pub execution: Execution, pub windows: Vec, pub active: u64, pub size: (u16, u16), pub menu: Option<(usize, usize)>, pub control_menu: bool, pub dialog: Option, pub properties: bool, pub value_focus: bool, pub selected_member: usize, pub message: String, pub quit: bool, pub last_command: Option, pub hits: Vec<(Rect, Hit)>, pub basic_events: Vec, pub last_export: Option<(ExportRequest, ExportStatus)>, pub config_path: PathBuf, pub saved_options: Options, config_disk: Option>, next_window: u64, geometry_action: Option<(bool, Rect)>, base: PathBuf, } impl App { pub fn load_initial_project(&mut self, path: PathBuf) -> Result<()> { self.finish_change(AfterSave::OpenProject(path)) } pub fn new(base: &Path, config_path: PathBuf, size: (u16, u16)) -> Result { let mut project = Project::new(base)?; let doc = untitled(&mut project)?; let (options, errors, config_disk) = Options::load(&config_path); project.include_paths = options.include_paths.clone(); let mut app = Self { session: Default::default(), editor: Default::default(), project, options: options.clone(), saved_options: options, config_path, config_disk, base: tb_vm::project_io::identity(base)?, mode: Mode::Environment, execution: Execution::Idle, windows: vec![], active: 0, size, menu: None, control_menu: false, dialog: None, properties: false, value_focus: false, selected_member: 0, message: format!( "Terminal Basic {} · Copyright {} · {}", env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_AUTHORS"), errors.join("; ") ), quit: false, last_command: None, hits: vec![], basic_events: vec![], last_export: None, next_window: 1, geometry_action: None, }; let width = size.0.max(80); let height = size.1.max(25); let view = app.project.open_view(doc)?; let code = app.add_window( WindowKind::Code(view), Rect::new(0, 1, width.saturating_sub(32), height - 3), ); app.add_window( WindowKind::Project, Rect::new(width - 32, 1, 32, height - 3), ); app.active = code; Ok(app) } pub fn area(&self) -> Rect { Rect::new( 0, 1, self.size.0, self.size .1 .saturating_sub(if self.mode == Mode::Designer { 1 } else { 2 }), ) } pub fn rect(&self, w: &Window) -> Rect { let area = self.area(); match w.state { WindowState::Maximized => area, WindowState::Minimized => Rect::new( 0, area.bottom().saturating_sub(2), 24.min(area.width), 2.min(area.height), ), WindowState::Normal => clamp(w.normal, area), } } fn add_window(&mut self, kind: WindowKind, normal: Rect) -> u64 { let id = self.next_window; self.next_window += 1; self.windows.push(Window { id, kind, normal, state: WindowState::Normal, }); self.active = id; id } pub fn active_window(&self) -> Option<&Window> { self.windows.iter().find(|w| w.id == self.active) } pub fn active_document(&self) -> Option { match self.active_window()?.kind { WindowKind::Code(v) => self.project.view(v).ok().map(|v| v.document()), _ => self.project.members().get(self.selected_member).copied(), } } pub fn title(&self, w: &Window) -> String { match w.kind { WindowKind::Code(v) => { let doc = self .project .document(self.project.view(v).unwrap().document()) .unwrap(); let name = doc .path() .and_then(|p| p.file_name()) .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| { doc.source_path() .file_stem() .unwrap_or_default() .to_string_lossy() .into_owned() }); format!( "[{}] {}{}{}", w.id, if self.editor.expansions.contains_key(&v) { "Included Lines [read-only] · " } else { "" }, name, if doc.is_dirty() { " *" } else { "" } ) } WindowKind::Project => format!( "Project: {}", self.project .path() .and_then(|p| p.file_name()) .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "Untitled".into()) ) .trim_end() .to_string(), WindowKind::Output if self.session.old_revision => "Output · ALTES KOMPILAT".into(), _ => format!("{:?}", w.kind), } } pub fn menus(&self) -> Vec { commands::menus(self.mode == Mode::Designer) } pub fn window_commands(&self) -> Vec<(String, Command)> { self.windows .iter() .filter(|w| matches!(w.kind, WindowKind::Code(_))) .filter(|w| self.mode!=Mode::Designer || matches!(w.kind,WindowKind::Code(v) if self.project.view(v).ok().and_then(|v|self.project.document(v.document()).ok()).is_some_and(|d|matches!(d.content(),Content::Form(_))))) .map(|w| { ( format!( "{}{}{}", if w.id == self.active { "• " } else { " " }, if self.mode==Mode::Designer {"Form: "}else{""}, self.title(w) ), Command::FocusWindow(w.id), ) }) .collect() } pub fn availability(&self, command: Command) -> Option { if let Some(phase) = command.feature_phase() { return Some(format!("Fachfunktion folgt in Phase-5-Change {phase:02}")); } use Command::*; if command == Shell && self.session.host.shell_request.is_some() { return Some("Shell-Übergabe bereits angefordert".into()); } if matches!( command, Undo | Cut | Paste | Clear | LoadText | NewSub | NewFunction | Replace ) && self .editor_view() .is_ok_and(|v| self.editor.expansions.contains_key(&v)) { return Some( "Included Lines ist schreibgeschützt; Included File öffnet die Originaldatei" .into(), ); } if matches!( command, LoadText | Print | SaveText | Cut | Copy | Paste | Clear | NewSub | NewFunction | IncludedFile | IncludedLines | Find | SelectedText | FindNext | Replace | Procedures | PreviousCode | Diagnostics ) && !matches!( self.active_window().map(|w| w.kind), Some(WindowKind::Code(_)) ) { return Some("Kein Codefenster aktiv".into()); } if matches!( command, SaveFile | SaveAs | LoadText | SaveText | Undo | NewWindow ) && self.active_document().is_none() { return Some("Kein Dokument ausgewählt".into()); } if matches!(command, Form) && !self .project .documents() .any(|(_, d)| matches!(d.content(), Content::Form(_))) { return Some("Kein Formular vorhanden".into()); } if matches!(command, RemoveFile | Startup) && self.project.members().is_empty() { return Some("Kein Projektmitglied vorhanden".into()); } if matches!( command, CloseWindow | MoveWindow | SizeWindow | Minimize | Maximize | Restore | ControlMenu ) && self.active_window().is_none() { return Some("Kein Fenster aktiv".into()); } None } pub fn execute(&mut self, command: Command) { self.last_command = Some(command); if let Some(reason) = self.availability(command) { self.message = reason; return; } self.menu = None; self.control_menu = false; if let Err(e) = self.action(command) { self.message = format!("{e:#}"); } } fn action(&mut self, command: Command) -> Result<()> { use Command::*; let id = self.active_document(); match command { Start => self.start_execution()?, Restart => self.restart_target()?, Continue => self.continue_execution()?, Pause => self.pause_execution(), OutputScreen => { self.session.fullscreen = !self.session.fullscreen; } CommandLine => self.open_dialog( "COMMAND$", DialogKind::CommandLine, vec![Field::text( "Argumente für den nächsten Start", &self.session.command, )], ), Shell => { self.session.file_shell = true; self.session.host.shell_request = Some(String::new()); } Print => self.open_dialog( "Print", DialogKind::Print, vec![ Field::text("UTF-8-Ausgabedatei", self.output_default("LPT1.TXT")), Field::toggle("Bestehendes Ziel überschreiben", false), Field::toggle( "Nur Auswahl", self.project .view(self.editor_view()?)? .selection() .is_some(), ), ], ), Cut | Copy | Paste | Clear | NewSub | NewFunction | IncludedFile | IncludedLines | Find | SelectedText | FindNext | Replace | Procedures | PreviousCode | Diagnostics => self.editor_command(command)?, NewProject => self.change_project(AfterSave::NewProject)?, OpenProject => self.open_dialog( "Open Project", DialogKind::OpenProject, vec![Field::text("Datei", self.source_default())], ), Exit => self.change_project(AfterSave::Exit)?, NewModule => self.open_dialog( "New Module", DialogKind::NewModule, vec![Field::text("Name", "Module1")], ), NewForm => { let mut n = 1; while self .project .loader()? .resolve(self.project.directory(), &format!("Form{n}.frm")) .is_ok() || self.project.documents().any(|(_, d)| { d.source_path() .file_stem() .is_some_and(|s| s.eq_ignore_ascii_case(format!("Form{n}").as_str())) }) { n += 1; } let id = self.project.new_form(&format!("Form{n}"))?; self.show_document(id, true)?; } AddFile => self.open_dialog( "Add File", DialogKind::AddFile, vec![Field::text("Datei", self.source_default())], ), SaveFile => { let id = id.unwrap(); if self.project.document(id)?.path().is_none() || self.project.save_file(id, None).is_err() { self.save_dialog(false, vec![id], AfterSave::Stay)?; } else { self.message = format!( "Datei gespeichert · {}", self.project.save_notices.join("; ") ); } } SaveAs => self.save_dialog(false, vec![id.unwrap()], AfterSave::Stay)?, SaveProject => self.save_dialog( true, self.project.documents().map(|(id, _)| id).collect(), AfterSave::Stay, )?, RemoveFile => { let ids = self.project.members(); let names = self.names(&ids); let mut replacements = vec!["Bisheriger Standard".into()]; replacements.extend(names.clone()); self.open_dialog( "Remove File", DialogKind::Remove, vec![ Field::choice("Mitglied", names, self.selected_member.min(ids.len() - 1)), Field::choice("Ersatz für Startdatei", replacements, 0), ], ); } Startup => { let ids = self.project.members(); let mut names = vec!["Bisheriger Standard".into()]; names.extend(self.names(&ids)); let selected = self .project .startup() .and_then(|id| ids.iter().position(|i| *i == id)) .map(|n| n + 1) .unwrap_or(0); self.open_dialog( "Set Start-up File", DialogKind::Startup, vec![Field::choice("Startdatei", names, selected)], ); } Code | Form => { let ids: Vec<_> = self .project .documents() .filter(|(_, d)| command == Code || matches!(d.content(), Content::Form(_))) .map(|(id, _)| id) .collect(); let selected = id .and_then(|id| ids.iter().position(|i| *i == id)) .unwrap_or(0); let names = self.names(&ids); self.open_dialog( if command == Code { "Code" } else { "Form" }, if command == Code { DialogKind::ChooseCode } else { DialogKind::ChooseForm }, vec![Field::choice("Dokument", names, selected)], ); } LoadText => self.open_dialog( "Load Text", DialogKind::LoadText, vec![Field::text("Datei", self.source_default())], ), SaveText => self.open_dialog( "Save Text", DialogKind::SaveText, vec![ Field::text("Datei", self.output_default("text.txt")), Field::toggle("Bestehendes Ziel überschreiben", false), ], ), Undo => { self.project.undo(id.unwrap())?; } NewWindow => { let view = self.project.open_view(id.unwrap())?; self.add_window(WindowKind::Code(view), self.cascade()); } Arrange => self.arrange(), NextWindow => self.cycle(1), PreviousWindow => self.cycle(-1), CloseWindow => { if let Some(i) = self.windows.iter().position(|w| w.id == self.active) { let w = self.windows.remove(i); if let WindowKind::Code(v) = w.kind { self.project.close_view(v)?; } self.active = self.windows.first().map(|w| w.id).unwrap_or(0); } } MoveWindow | SizeWindow => { if let Some(w) = self.windows.iter().find(|w| w.id == self.active) { self.geometry_action = Some((command == SizeWindow, w.normal)); self.message = "Pfeile ändern Fenster; Enter bestätigt, Esc verwirft".into(); } } Minimize | Maximize | Restore => { if let Some(w) = self.windows.iter_mut().find(|w| w.id == self.active) { w.state = match command { Minimize => WindowState::Minimized, Maximize => WindowState::Maximized, _ => WindowState::Normal, }; } } ControlMenu => { self.control_menu = true; self.menu = Some((0, 0)); } FocusWindow(id) => { if self.windows.iter().any(|w| w.id == id) { self.active = id; } } Calls | Debug | HelpWindow | Immediate | Output | Project => { let kind = match command { Calls => WindowKind::Calls, Debug => WindowKind::Debug, HelpWindow => WindowKind::Help, Immediate => WindowKind::Immediate, Output => WindowKind::Output, _ => WindowKind::Project, }; self.show_tool(kind); if command == Output { self.session.fullscreen = false; } } MenuBar => { self.properties = !self.properties; self.value_focus = false; self.menu = if self.properties { None } else { Some((0, 0)) }; } Display => { let mut fields = vec![]; for (i, name) in ELEMENTS.iter().enumerate() { fields.push(Field::choice( format!("{name} Vordergrund"), (0..16).map(|n| n.to_string()).collect(), self.options.colors[i].0 as usize, )); fields.push(Field::choice( format!("{name} Hintergrund"), (0..16).map(|n| n.to_string()).collect(), self.options.colors[i].1 as usize, )); } fields.extend([ Field::text("Desktopzeichen", self.options.desktop.to_string()), Field::text("Tabweite", self.options.tab_width.to_string()), ]); self.open_dialog("Display", DialogKind::Display, fields); } Paths => self.open_dialog( "Set Paths", DialogKind::Paths, vec![ Field::text("Quellen", self.options.source_dir.display().to_string()), Field::text("Ausgaben", self.options.output_dir.display().to_string()), Field::text( "Include-Pfade (| getrennt)", self.options .include_paths .iter() .map(|p| p.display().to_string()) .collect::>() .join("|"), ), ], ), RightMouse => self.open_dialog( "Right Mouse", DialogKind::RightMouse, vec![Field::toggle( "Rechtsklick öffnet Kontext-Hilfe", self.options.right_help, )], ), SaveOptions => self.open_dialog("Save Options", DialogKind::SaveOptions, vec![]), SyntaxChecking => { self.options.syntax_checking = !self.options.syntax_checking; self.message = format!("Syntax Checking: {}", self.options.syntax_checking); } About => self.open_dialog( format!( "Terminal Basic {} · Copyright {}", env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_AUTHORS") ), DialogKind::Message, vec![], ), MakeExe | MakeLibrary => { let artifact = if command == MakeExe { Artifact::Executable } else { Artifact::Library }; let systems = vec!["linux".into(), "macos".into(), "windows".into()]; let selected = systems .iter() .position(|s| s == std::env::consts::OS) .unwrap_or(0); let architectures = vec!["x86_64".into(), "aarch64".into()]; let arch = architectures .iter() .position(|s| s == std::env::consts::ARCH) .unwrap_or(0); self.open_dialog( if command == MakeExe { "Make EXE File" } else { "Make Library" }, DialogKind::Export(artifact), vec![ Field::text("Artefakt", artifact.label()), Field::choice("Zielsystem", systems, selected), Field::choice("Architektur", architectures, arch), Field::text( "Ausgabepfad", self.output_default(if artifact == Artifact::Executable { "program" } else { "library" }), ), Field::toggle("Bestehendes Ziel überschreiben", false), ], ); } _ => {} } Ok(()) } fn names(&self, ids: &[DocumentId]) -> Vec { ids.iter() .map(|id| { self.project .document(*id) .unwrap() .source_path() .file_name() .unwrap_or_default() .to_string_lossy() .into_owned() }) .collect() } fn source_default(&self) -> String { if self.options.source_dir.as_os_str().is_empty() { self.base.display().to_string() } else { self.options.source_dir.display().to_string() } } fn output_default(&self, name: &str) -> String { if self.options.output_dir.as_os_str().is_empty() { self.base.join(name) } else { self.options.output_dir.join(name) } .display() .to_string() } pub(crate) fn open_dialog( &mut self, title: impl Into, kind: DialogKind, fields: Vec, ) { self.dialog = Some(Dialog::new(title, kind, fields)); } fn cascade(&self) -> Rect { let offset = (self.windows.len() % 5) as u16 * 2; clamp(Rect::new(offset, 1 + offset, 60, 16), self.area()) } pub(crate) fn show_tool(&mut self, kind: WindowKind) { if let Some(w) = self.windows.iter_mut().find(|w| w.kind == kind) { self.active = w.id; w.state = WindowState::Normal; } else { self.add_window(kind, self.cascade()); } } pub(crate) fn show_document(&mut self, id: DocumentId, form: bool) -> Result<()> { if form { ensure!( matches!(self.project.document(id)?.content(), Content::Form(_)), "Kein Formular" ); } if let Some(w)=self.windows.iter_mut().find(|w|matches!(w.kind,WindowKind::Code(v) if self.project.view(v).is_ok_and(|v|v.document()==id))){self.active=w.id;w.state=WindowState::Normal;}else{let view=self.project.open_view(id)?;self.add_window(WindowKind::Code(view),self.cascade());} self.mode = if form { Mode::Designer } else { Mode::Environment }; self.properties = form; self.value_focus = false; Ok(()) } fn cycle(&mut self, direction: isize) { if self.windows.is_empty() { return; } let i = self .windows .iter() .position(|w| w.id == self.active) .unwrap_or(0); let i = (i as isize + direction).rem_euclid(self.windows.len() as isize) as usize; self.active = self.windows[i].id; } fn arrange(&mut self) { let area = self.area(); let n = self.windows.len(); if n == 0 { return; } let cols = (n as f64).sqrt().ceil() as u16; let rows = (n as u16).div_ceil(cols); for (i, w) in self.windows.iter_mut().enumerate() { let col = i as u16 % cols; let row = i as u16 / cols; let x = area.width * col / cols; let right = area.width * (col + 1) / cols; let y = area.height * row / rows; let bottom = area.height * (row + 1) / rows; w.normal = Rect::new(x, area.y + y, right - x, bottom - y); w.state = WindowState::Normal; } } fn change_project(&mut self, after: AfterSave) -> Result<()> { if self.project.is_dirty() { self.open_dialog( "Ungespeicherte Änderungen", DialogKind::Dirty(after), vec![Field::choice( "Entscheidung", vec!["Abbrechen".into(), "Speichern".into(), "Verwerfen".into()], 0, )], ); } else { self.finish_change(after)?; } Ok(()) } fn finish_change(&mut self, after: AfterSave) -> Result<()> { match after { AfterSave::Stay => return Ok(()), AfterSave::Exit => { self.session.finish(); self.basic_events.clear(); self.quit = true; return Ok(()); } AfterSave::NewProject => { self.project.new_project(Decision::Discard)?; untitled(&mut self.project)?; } AfterSave::OpenProject(path) => { self.project.open_project(&path, Decision::Discard)?; } } self.session = Default::default(); self.basic_events.clear(); self.execution = Execution::Idle; self.base = self.project.directory().to_path_buf(); self.editor = Default::default(); self.windows.clear(); self.active = 0; self.selected_member = 0; self.mode = Mode::Environment; self.properties = false; if let Some(id) = self.project.members().first().copied() { self.show_document(id, false)?; } let active = self.active; self.show_tool(WindowKind::Project); if active != 0 { self.active = active; } self.arrange(); Ok(()) } fn save_dialog(&mut self, project: bool, ids: Vec, after: AfterSave) -> Result<()> { let mut fields = vec![]; if project { fields.push(Field::text( "Projekt (.mak)", self.project .path() .map(|p| p.display().to_string()) .unwrap_or_else(|| self.output_default("project.mak")), )); fields.push(Field::toggle("Projektziel überschreiben", false)); } for id in &ids { let doc = self.project.document(*id)?; fields.push(Field::text( self.names(&[*id])[0].clone(), doc.path() .map(|p| p.display().to_string()) .unwrap_or_else(|| doc.source_path().display().to_string()), )); fields.push(Field::toggle("Dateiziel überschreiben", false)); } self.open_dialog( if project { "Save Project" } else { "Save File As" }, DialogKind::Save { project, ids, after, }, fields, ); Ok(()) } fn submit_dialog(&mut self) { let Some(mut d) = self.dialog.take() else { return; }; match self.submit(&mut d) { Ok(keep) => { if keep { self.dialog = Some(d); } } Err(e) => { d.error = format!("{e:#}"); self.dialog = Some(d); } } } fn submit(&mut self, d: &mut Dialog) -> Result { match d.kind.clone() { DialogKind::Search { .. } | DialogKind::Procedure(_) | DialogKind::Procedures(_) | DialogKind::Diagnostics => return self.editor_submit(d), DialogKind::Browse { mut return_to, field, entries, } => { let path = entries .get(d.fields[0].index()) .ok_or_else(|| anyhow!("Keine Datei ausgewählt"))?; if path.is_dir() { self.dialog = Some(Self::browser(*return_to, field, path)?); } else { return_to.fields[field] = Field::text( return_to.fields[field].label.clone(), path.display().to_string(), ); return_to.request = None; self.dialog = Some(*return_to); } } DialogKind::Message => {} DialogKind::OpenProject => { let path = self.base.join(d.fields[0].string()); Project::open(&path, self.options.include_paths.clone())?; self.change_project(AfterSave::OpenProject(path))?; } DialogKind::NewModule => { let id = self.project.new_module(&d.fields[0].string())?; self.show_document(id, false)?; } DialogKind::AddFile => { let id = self.project.add_file(Path::new(&d.fields[0].string()))?; self.show_document(id, false)?; } DialogKind::ChooseCode | DialogKind::ChooseForm => { let form = matches!(d.kind, DialogKind::ChooseForm); let ids: Vec<_> = self .project .documents() .filter(|(_, d)| !form || matches!(d.content(), Content::Form(_))) .map(|(id, _)| id) .collect(); let id = *ids .get(d.fields[0].index()) .ok_or_else(|| anyhow!("Kein Dokument"))?; self.show_document(id, form)?; } DialogKind::Remove => { let ids = self.project.members(); let id = ids[d.fields[0].index()]; let replacement = d.fields[1].index(); self.project.remove_file( id, Some(if replacement == 0 { StartupRemoval::Default } else { StartupRemoval::Replace(ids[replacement - 1]) }), )?; self.selected_member = self .selected_member .min(self.project.members().len().saturating_sub(1)); } DialogKind::Startup => { let ids = self.project.members(); let choice = d.fields[0].index(); self.project.set_startup(if choice == 0 { None } else { Some(ids[choice - 1]) })?; } DialogKind::Dirty(after) => match d.fields[0].index() { 1 => self.save_dialog( true, self.project.documents().map(|(id, _)| id).collect(), after, )?, 2 => self.finish_change(after)?, _ => {} }, DialogKind::Save { project, ids, after, } => { let mut plan = SavePlan::default(); let mut offset = 0; if project { plan.project = Some(Destination { path: d.fields[0].string().into(), overwrite: d.fields[1].flag(), }); offset = 2; } for (i, id) in ids.iter().enumerate() { let path = PathBuf::from(d.fields[offset + 2 * i].string()); let doc = self.project.document(*id)?; // Unveränderte Mitglieder brauchen keinen erneuten Schreibzugriff (insbesondere Binärimporte). if !project || doc.is_dirty() || doc.path() != Some(path.as_path()) { plan.files.insert( *id, Destination { path, overwrite: d.fields[offset + 2 * i + 1].flag(), }, ); } } if project { self.project.save_project(&plan)?; self.base = self.project.path().unwrap().parent().unwrap().to_path_buf(); } else { self.project.save_file(ids[0], plan.files.get(&ids[0]))?; } self.message = format!("Gespeichert · {}", self.project.save_notices.join("; ")); self.finish_change(after)?; } DialogKind::LoadText => { let text = std::fs::read_to_string(self.base.join(d.fields[0].string()))?; self.editor_insert(&text)?; } DialogKind::CommandLine => { self.session.command = d.fields[0].string(); } DialogKind::ResumeRevision => { if d.fields[0].index() == 0 { self.restart_target()?; } else { self.resume_execution(true); } } DialogKind::SaveText | DialogKind::Print => { let (id, _) = self.code_cursor()?; self.project.save_text( id, if matches!(d.kind, DialogKind::Print) && !d.fields[2].flag() { None } else { self.project.view(self.editor_view()?)?.selection() }, &Destination { path: d.fields[0].string().into(), overwrite: d.fields[1].flag(), }, )?; } DialogKind::Display => { let mut options = self.options.clone(); for i in 0..7 { options.colors[i] = ( d.fields[2 * i].index() as u8, d.fields[2 * i + 1].index() as u8, ); } let s = d.fields[14].string(); ensure!(s.chars().count() == 1, "Ein Desktopzeichen erforderlich"); options.desktop = s.chars().next().unwrap(); options.tab_width = d.fields[15].string().parse()?; options.validate()?; self.options = options; } DialogKind::Paths => { let mut options = self.options.clone(); let path = |s: String| { if s.is_empty() { PathBuf::new() } else { self.base.join(s) } }; options.source_dir = path(d.fields[0].string()); options.output_dir = path(d.fields[1].string()); options.include_paths = d.fields[2] .string() .split('|') .filter(|s| !s.is_empty()) .map(|s| path(s.into())) .collect(); options.validate()?; self.project.include_paths = options.include_paths.clone(); self.options = options; } DialogKind::RightMouse => self.options.right_help = d.fields[0].flag(), DialogKind::SaveOptions => { self.config_disk = Some( self.options .save(&self.config_path, self.config_disk.as_deref())?, ); self.saved_options = self.options.clone(); self.message = format!("Optionen gespeichert: {}", self.config_path.display()); } DialogKind::Export(artifact) => { let request = ExportRequest::validate( &self.project, artifact, &d.fields[1].string(), &d.fields[2].string(), &Destination { path: d.fields[3].string().into(), overwrite: d.fields[4].flag(), }, )?; d.request = Some(request); d.export_status = ExportStatus::Ready; d.error = crate::export::UNAVAILABLE.into(); return Ok(true); } } Ok(false) } /// Die spätere Anbindung und Oberflächentests liefern denselben konkreten Auftrag zurück. pub fn export_result(&mut self, request: &ExportRequest, status: ExportStatus) -> Result<()> { let d = self .dialog .as_mut() .ok_or_else(|| anyhow!("Kein Exportdialog offen"))?; ensure!( d.request.as_ref() == Some(request), "Exportauftrag stimmt nicht mit dem Dialog überein" ); ensure!( request.project == crate::export::ProjectStamp::capture(&self.project), "Projekt wurde seit dem Exportauftrag geändert" ); d.export_status = status.clone(); d.error.clear(); self.last_export = Some((request.clone(), status)); Ok(()) } fn code_cursor(&self) -> Result<(DocumentId, usize)> { let Some(Window { kind: WindowKind::Code(view), .. }) = self.active_window() else { return Err(anyhow!("Kein Codefenster aktiv")); }; let view = self.project.view(*view)?; Ok((view.document(), view.cursor)) } pub fn handle(&mut self, event: Event) { let old = self.editor_position(); if !matches!(event, Event::Key(_) | Event::Resize(_, _)) { self.editor.chord = None; } self.handle_event(event); self.line_leave(old); } fn handle_event(&mut self, event: Event) { if matches!(event, Event::Key(k) if k.kind != KeyEventKind::Release && k.code == K::Pause && k.modifiers.contains(M::CONTROL)) { self.pause_execution(); return; } if let Event::Resize(w, h) = event { self.size = (w, h); if let Some(vm) = self.session.vm.as_mut() { vm.rt.ereignis(tb_runtime::host::Ereignis::Groesse { cols: w as usize, rows: h as usize, }); vm.forms.resize(w as usize, h as usize); } return; } if self.size.0 < 80 || self.size.1 < 25 { return; } if let Event::Key(key) = event { if key.kind == KeyEventKind::Release { return; } self.key(key); } else if let Event::Mouse(mouse) = event { if self.dialog.is_none() && self.menu.is_none() && self.program_focus() { if self.session.fullscreen { self.basic_events.push(Event::Mouse(mouse)); return; } if let Some(w) = self.active_window() { let r = self.rect(w); let inner = Rect::new( r.x + 1, r.y + 1, r.width.saturating_sub(2), r.height.saturating_sub(2), ); if inner.contains((mouse.column, mouse.row).into()) { let mut mouse = mouse; mouse.column -= inner.x; mouse.row -= inner.y; self.basic_events.push(Event::Mouse(mouse)); return; } } } if mouse.kind == MouseEventKind::Down(MouseButton::Right) { if self.dialog.is_none() && self.menu.is_none() && self.options.right_help { self.execute(Command::Topic); } return; } if mouse.kind == MouseEventKind::Down(MouseButton::Left) { let hit = self .hits .iter() .rev() .find(|(r, _)| r.contains((mouse.column, mouse.row).into())) .map(|(_, hit)| *hit); if let Some(hit) = hit { self.click(hit); } } else if self.dialog.is_none() && self.menu.is_none() { if let Some(Window { kind: WindowKind::Code(v), .. }) = self.active_window() { let v = *v; if let Ok(view) = self.project.view_mut(v) { match mouse.kind { MouseEventKind::ScrollDown => view.scroll_line += 1, MouseEventKind::ScrollUp => { view.scroll_line = view.scroll_line.saturating_sub(1) } _ => {} } } } } } else if self.program_focus() { self.basic_events.push(event); } else if let Event::Paste(text) = event { if self.dialog.is_none() && self.menu.is_none() && self.mode == Mode::Environment { if let Err(e) = self.editor_insert(&text) { self.message = e.to_string(); } } } } fn click(&mut self, hit: Hit) { if self.dialog.is_some() { match hit { Hit::DialogField(i) => { let d = self.dialog.as_mut().unwrap(); d.focus = i.min(d.fields.len()); if let Some(field) = d.fields.get_mut(i) { if !matches!(field.value, FieldValue::Text(_)) { field.key(KeyEvent::new(K::Right, M::NONE)); d.request = None; d.export_status = ExportStatus::Ready; } } } Hit::DialogSubmit => self.submit_dialog(), Hit::DialogCancel => self.cancel_dialog(), _ => {} } return; } if self.menu.is_some() { match hit { Hit::Menu(i) => self.menu = Some((i, 0)), Hit::MenuItem(i) => { if let Some((menu, _)) = self.menu { self.menu = Some((menu, i)); self.menu_enter(); } } _ => { self.menu = None; self.control_menu = false; } } return; } match hit { Hit::Command(c) => self.execute(c), Hit::Menu(i) => { self.properties = false; self.menu = Some((i, 0)); } Hit::Window(id) => self.active = id, Hit::ProjectMember(i) => { self.selected_member = i; if let Some(w) = self.windows.iter().find(|w| w.kind == WindowKind::Project) { self.active = w.id; } } Hit::ProjectView(form) => { self.last_command = Some(if form { Command::Form } else { Command::Code }); if let Some(id) = self.project.members().get(self.selected_member).copied() { if let Err(e) = self.show_document(id, form) { self.message = e.to_string(); } } } _ => {} } } fn cancel_dialog(&mut self) { if let Some(d) = self.dialog.take() { if let DialogKind::Browse { return_to, .. } = d.kind { self.dialog = Some(*return_to); return; } if let Some(request) = d.request { self.last_export = Some((request, ExportStatus::Cancelled)); } } } fn program_focus(&self) -> bool { matches!(self.execution, Execution::Running | Execution::Waiting) && (self.session.fullscreen || matches!( self.active_window().map(|w| w.kind), Some(WindowKind::Output) )) } fn key(&mut self, key: KeyEvent) { if self.session.fullscreen && self.dialog.is_none() && key.code == K::F(4) { self.execute(Command::OutputScreen); return; } if self.editor.chord.is_some() && self.dialog.is_none() && self.menu.is_none() && !self.program_focus() { if let Err(e) = self.editor_key(key) { self.message = e.to_string(); } return; } if key.code == K::F(2) && self.dialog.is_some() { self.browse(); return; } if let Some(d) = self.dialog.as_mut() { match key.code { K::Esc => self.cancel_dialog(), K::Enter => self.submit_dialog(), K::Tab if key.modifiers.contains(M::SHIFT) => { d.focus = (d.focus + d.fields.len()) % (d.fields.len() + 1) } K::BackTab | K::Up => d.focus = (d.focus + d.fields.len()) % (d.fields.len() + 1), K::Tab | K::Down => d.focus = (d.focus + 1) % (d.fields.len() + 1), _ => { if let Some(field) = d.fields.get_mut(d.focus) { if !(matches!(d.kind, DialogKind::Export(_)) && d.focus == 0) { field.key(key); d.request = None; d.export_status = ExportStatus::Ready; } } } } return; } if self.mode == Mode::Designer && key.code == K::F(10) && !key.modifiers.contains(M::CONTROL) { self.menu = None; self.execute(Command::MenuBar); return; } if self.menu.is_some() { if key.modifiers.contains(M::ALT) { if let K::Char(c) = key.code { if let Some(i) = self .menus() .iter() .position(|m| m.mnemonic == c.to_ascii_lowercase()) { self.control_menu = false; self.menu = Some((i, 0)); return; } } } match key.code { K::Esc => { self.menu = None; self.control_menu = false; } K::Left | K::Right if !self.control_menu => { let n = self.menus().len(); let (m, _) = self.menu.unwrap(); self.menu = Some(( (m + n + if key.code == K::Right { 1 } else { n - 1 }) % n, 0, )); } K::Up | K::Down => { let (m, i) = self.menu.unwrap(); let n = self.menu_entries(m).len(); if n > 0 { self.menu = Some((m, (i + n + if key.code == K::Down { 1 } else { n - 1 }) % n)); } } K::Enter => self.menu_enter(), K::Char(c) => { let (m, _) = self.menu.unwrap(); let entries = self.menu_entries(m); if let Some(i) = entries.iter().position(|(label, _)| { label .split_once('&') .and_then(|(_, s)| s.chars().next()) .is_some_and(|v| v.eq_ignore_ascii_case(&c)) }) { self.menu = Some((m, i)); self.menu_enter(); } } _ => {} } return; } if let Some((sizing, original)) = self.geometry_action { match key.code { K::Esc => { if let Some(w) = self.windows.iter_mut().find(|w| w.id == self.active) { w.normal = original; } self.geometry_action = None; } K::Enter => self.geometry_action = None, K::Left | K::Right | K::Up | K::Down => { let area = self.area(); if let Some(w) = self.windows.iter_mut().find(|w| w.id == self.active) { let delta = if key.modifiers.contains(M::CONTROL) { 5 } else { 1 }; let grow = matches!(key.code, K::Right | K::Down); let value = match (sizing, key.code) { (true, K::Left | K::Right) => &mut w.normal.width, (true, _) => &mut w.normal.height, (false, K::Left | K::Right) => &mut w.normal.x, (false, _) => &mut w.normal.y, }; *value = if grow { value.saturating_add(delta) } else { value.saturating_sub(delta) }; w.normal = clamp(w.normal, area); } } _ => {} } return; } if key.code == K::F(11) || matches!( key.code, K::Modifier(ModifierKeyCode::LeftAlt | ModifierKeyCode::RightAlt) ) { self.properties = false; self.menu = Some((0, 0)); return; } if key.modifiers.contains(M::ALT) { match key.code { K::Char('-') => { self.execute(Command::ControlMenu); return; } K::F(4) => { self.execute(Command::Exit); return; } K::Char(c) => { if let Some(i) = self .menus() .iter() .position(|m| m.mnemonic == c.to_ascii_lowercase()) { self.properties = false; self.menu = Some((i, 0)); return; } } _ => {} } } if self.mode == Mode::Designer && key.code == K::F(10) && !key.modifiers.contains(M::CONTROL) { self.execute(Command::MenuBar); return; } if self.mode == Mode::Designer && key.code == K::F(2) { self.properties = true; self.value_focus = true; self.message = "Properties Value · Bearbeitung folgt in Change 05".into(); return; } if self.program_focus() && key.modifiers.contains(M::CONTROL) && key.code == K::Char('c') { self.pause_execution(); return; } if let Some(command) = shortcut(key) { self.execute(command); return; } if self.program_focus() { self.basic_events.push(Event::Key(key)); return; } if matches!( self.active_window().map(|w| w.kind), Some(WindowKind::Project) ) { let n = self.project.members().len(); match key.code { K::Down if n > 0 => self.selected_member = (self.selected_member + 1) % n, K::Up if n > 0 => self.selected_member = (self.selected_member + n - 1) % n, K::Enter if n > 0 => { let id = self.project.members()[self.selected_member]; let form = matches!( self.project.document(id).unwrap().content(), Content::Form(_) ); if let Err(e) = self.show_document(id, form) { self.message = e.to_string(); } } _ => {} } return; } if self.mode == Mode::Environment { if let Err(e) = self.code_key(key) { self.message = e.to_string(); } } } fn browse(&mut self) { let Some(dialog) = self.dialog.take() else { return; }; let index = dialog.focus; let allowed = match dialog.kind { DialogKind::OpenProject | DialogKind::AddFile | DialogKind::LoadText | DialogKind::SaveText | DialogKind::Print => index == 0, DialogKind::Save { .. } => index % 2 == 0, DialogKind::Export(_) => index == 3, _ => false, }; if !allowed || index >= dialog.fields.len() { self.dialog = Some(dialog); return; } let path = self.base.join(dialog.fields[index].string()); let dir = if path.is_dir() { path } else { path.parent().unwrap_or(&self.base).to_path_buf() }; match Self::browser(dialog.clone(), index, &dir) { Ok(d) => self.dialog = Some(d), Err(e) => { let mut dialog = dialog; dialog.error = e.to_string(); self.dialog = Some(dialog); } } } fn browser(return_to: Dialog, field: usize, dir: &Path) -> Result { let dir = dir.canonicalize()?; let mut entries = std::fs::read_dir(&dir)? .map(|e| e.map(|e| e.path())) .collect::>>()?; entries.sort_by_key(|p| (!p.is_dir(), p.clone())); if let Some(parent) = dir.parent() { entries.insert(0, parent.to_path_buf()); } let names = entries .iter() .map(|p| { format!( "{}{}", p.file_name().unwrap_or_default().to_string_lossy(), if p.is_dir() { "/" } else { "" } ) }) .collect(); Ok(Dialog::new( format!("Dateiwahl: {}", dir.display()), DialogKind::Browse { return_to: Box::new(return_to), field, entries, }, vec![Field::choice("Datei/Verzeichnis", names, 0)], )) } fn code_key(&mut self, key: KeyEvent) -> Result<()> { if self.editor_view().is_err() { return Ok(()); } self.editor_key(key) } pub fn menu_entries(&self, index: usize) -> Vec<(String, Option)> { if self.control_menu { return vec![ ("&Restore".into(), Some(Command::Restore)), ("&Move".into(), Some(Command::MoveWindow)), ("&Size".into(), Some(Command::SizeWindow)), ("Mi&nimize".into(), Some(Command::Minimize)), ("Ma&ximize".into(), Some(Command::Maximize)), ("&Close".into(), Some(Command::CloseWindow)), ]; } let menus = self.menus(); let mut entries = menus[index] .items .iter() .map(|i| (i.label.into(), i.command)) .collect::>(); if menus[index].title == "Window" { entries.extend( self.window_commands() .into_iter() .enumerate() .map(|(i, (label, c))| (format!("&{} {label}", i + 1), Some(c))), ); } entries } fn menu_enter(&mut self) { if let Some((m, i)) = self.menu { if let Some((_, Some(c))) = self.menu_entries(m).get(i) { self.execute(*c); } } } } pub fn clamp(r: Rect, area: Rect) -> Rect { let width = r.width.max(12).min(area.width); let height = r.height.max(4).min(area.height); Rect::new( r.x.clamp(area.x, area.right().saturating_sub(width)), r.y.clamp(area.y, area.bottom().saturating_sub(height)), width, height, ) } fn untitled(project: &mut Project) -> Result { let mut n = 0; loop { let name = if n == 0 { "Untitled".into() } else { format!("Untitled{n}") }; if project .loader()? .resolve(project.directory(), &format!("{name}.bas")) .is_err() { return project.new_module(&name); } n += 1; } } pub fn shortcut(key: KeyEvent) -> Option { use Command::*; let ctrl = key.modifiers.contains(M::CONTROL); let shift = key.modifiers.contains(M::SHIFT); Some(match key.code { K::F(6) => { if shift { PreviousWindow } else { NextWindow } } K::F(4) if ctrl => CloseWindow, K::F(7) if ctrl => MoveWindow, K::F(8) if ctrl => SizeWindow, K::F(9) if ctrl => Minimize, K::F(10) if ctrl => Maximize, K::F(5) if ctrl => Restore, K::F(1) if shift => UsingHelp, K::F(1) => Topic, K::F(2) if shift => Procedures, K::F(2) if ctrl => PreviousCode, K::F(2) => Code, K::F(3) => FindNext, K::F(4) => OutputScreen, K::F(5) if shift => Start, K::F(5) => Continue, K::F(7) => RunToCursor, K::F(8) if shift => HistoryBack, K::F(8) => Step, K::F(9) if shift => InstantWatch, K::F(9) => Breakpoint, K::F(10) if shift => HistoryForward, K::F(10) => ProcedureStep, K::F(12) if shift => Form, K::F(12) => Events, K::Insert if ctrl => Copy, K::Insert if shift => Paste, K::Delete if shift => Cut, K::Backspace if key.modifiers.contains(M::ALT) => Undo, K::Char('c') if ctrl => Copy, K::Char('x') if ctrl => Cut, K::Char('v') if ctrl => Paste, K::Char('z') if ctrl => Undo, K::Char('\\') if ctrl => SelectedText, K::Delete => Clear, _ => return None, }) }