//! Embedded Markdown documentation. No filesystem, process or network access. use crate::{ app::{App, Mode, WindowKind}, commands::Command, }; use anyhow::{anyhow, ensure, Result}; use crossterm::event::{KeyCode as K, KeyEvent, KeyModifiers as M}; use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, widgets::Paragraph, Frame, }; use std::{collections::BTreeSet, sync::OnceLock}; use unicode_width::UnicodeWidthChar; const DOCUMENTS: &[(&str, &str)] = &[ ("docs/hilfe.md", include_str!("../../../docs/hilfe.md")), ( "docs/tutorial.md", include_str!("../../../docs/tutorial.md"), ), ( "docs/tastatur.md", include_str!("../../../docs/tastatur.md"), ), ( "docs/ide-bedienung.md", include_str!("../../../docs/ide-bedienung.md"), ), ( "docs/sprachreferenz.md", include_str!("../../../docs/sprachreferenz.md"), ), ( "docs/bibliothek.md", include_str!("../../../docs/bibliothek.md"), ), ( "docs/forms-referenz.md", include_str!("../../../docs/forms-referenz.md"), ), ( "docs/dateiformate.md", include_str!("../../../docs/dateiformate.md"), ), ( "docs/inventar.md", include_str!("../../../docs/inventar.md"), ), ( "docs/ide-referenz.md", include_str!("../../../docs/ide-referenz.md"), ), ( "docs/tbvm-design.md", include_str!("../../../docs/tbvm-design.md"), ), ( "docs/pcode-bibliotheken.md", include_str!("../../../docs/pcode-bibliotheken.md"), ), ( "docs/native-executables.md", include_str!("../../../docs/native-executables.md"), ), ("PLAN.md", include_str!("../../../PLAN.md")), ]; #[derive(Clone, Debug)] pub struct Glyph { pub ch: char, pub style: Style, pub link: Option, } #[derive(Clone, Debug, Default)] pub struct Block { pub glyphs: Vec, pub pre: bool, } impl Block { pub fn text(&self) -> String { self.glyphs.iter().map(|g| g.ch).collect() } } #[derive(Clone, Debug)] pub struct Link { pub label: String, pub target: String, } #[derive(Clone, Debug)] pub struct Heading { pub title: String, pub anchor: String, pub block: usize, pub level: usize, } #[derive(Clone, Debug)] pub struct Page { pub path: String, pub title: String, pub blocks: Vec, pub links: Vec, pub headings: Vec, } pub fn slug(text: &str) -> String { text.to_lowercase() .chars() .filter_map(|c| { if c.is_alphanumeric() || c == '_' || c == '-' { Some(c) } else if c.is_whitespace() { Some('-') } else { None } }) .collect() } pub fn normalize(text: &str) -> String { text.trim() .trim_end_matches(['$', '%', '&', '!', '#']) .to_uppercase() } fn words(text: &str) -> Vec { text.split(|c: char| !c.is_alphanumeric() && !matches!(c, '_' | '$' | '%' | '&' | '!' | '#')) .filter(|s| !s.is_empty()) .map(normalize) .collect() } fn append(block: &mut Block, text: &str, style: Style, link: Option) { block .glyphs .extend(text.chars().map(|ch| Glyph { ch, style, link })); } fn flush(blocks: &mut Vec, block: &mut Block) { if !block.glyphs.is_empty() { blocks.push(std::mem::take(block)); } } impl Page { pub fn parse(path: &str, markdown: &str) -> Self { let mut page = Self { path: path.into(), title: path.into(), blocks: vec![], links: vec![], headings: vec![], }; let mut block = Block::default(); let mut pre = false; let mut heading = None; let mut link = None; let mut strong = 0usize; let mut emphasis = 0usize; let mut lists = Vec::>::new(); let mut anchors = BTreeSet::new(); for event in Parser::new_ext(markdown, Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS) { let mut style = Style::default(); if strong > 0 || heading.is_some() { style = style.add_modifier(Modifier::BOLD); } if emphasis > 0 { style = style.add_modifier(Modifier::ITALIC); } if heading.is_some() { style = style.fg(crate::render::dos(1)); } match event { Event::Start(Tag::Heading { level, .. }) => { flush(&mut page.blocks, &mut block); heading = Some(level as usize); } Event::End(TagEnd::Heading(_)) => { let title = block.text(); let base = slug(&title); let mut anchor = base.clone(); let mut suffix = 0; while !anchors.insert(anchor.clone()) { suffix += 1; anchor = format!("{base}-{suffix}"); } if page.headings.is_empty() { page.title = title.clone(); } page.headings.push(Heading { title, anchor, block: page.blocks.len(), level: heading.take().unwrap(), }); flush(&mut page.blocks, &mut block); } Event::Start(Tag::CodeBlock(_)) => { flush(&mut page.blocks, &mut block); pre = true; block.pre = true; } Event::End(TagEnd::CodeBlock) => { flush(&mut page.blocks, &mut block); pre = false; } Event::Start(Tag::Table(_)) => { flush(&mut page.blocks, &mut block); pre = true; block.pre = true; } Event::End(TagEnd::Table) => { flush(&mut page.blocks, &mut block); pre = false; } Event::End(TagEnd::TableHead | TagEnd::TableRow) => { flush(&mut page.blocks, &mut block); block.pre = true; } Event::Start(Tag::TableCell) => append(&mut block, " | ", style, None), Event::End(TagEnd::TableCell) => {} Event::Start(Tag::List(start)) => { flush(&mut page.blocks, &mut block); lists.push(start); } Event::End(TagEnd::List(_)) => { flush(&mut page.blocks, &mut block); lists.pop(); } Event::Start(Tag::Item) => { flush(&mut page.blocks, &mut block); let prefix = match lists.last_mut() { Some(Some(n)) => { let p = format!("{n}. "); *n += 1; p } _ => "• ".into(), }; append( &mut block, &format!("{}{prefix}", " ".repeat(lists.len().saturating_sub(1))), style, None, ); } Event::End(TagEnd::Item | TagEnd::Paragraph) => flush(&mut page.blocks, &mut block), Event::Start(Tag::Strong) => strong += 1, Event::End(TagEnd::Strong) => strong = strong.saturating_sub(1), Event::Start(Tag::Emphasis) => emphasis += 1, Event::End(TagEnd::Emphasis) => emphasis = emphasis.saturating_sub(1), Event::Start(Tag::Link { dest_url, .. } | Tag::Image { dest_url, .. }) => { link = Some(page.links.len()); page.links.push(Link { label: String::new(), target: dest_url.into_string(), }); append(&mut block, "◄", style, link); } Event::End(TagEnd::Link | TagEnd::Image) => { append(&mut block, "►", style, link); if let Some(id) = link.take() { if external(&page.links[id].target) { append( &mut block, &format!(" [extern: {}]", page.links[id].target), style, Some(id), ); } } } Event::Text(text) | Event::Code(text) | Event::InlineHtml(text) | Event::Html(text) => { if let Some(id) = link { page.links[id].label.push_str(&text); } if pre { for (n, line) in text.split('\n').enumerate() { if n > 0 { page.blocks.push(std::mem::take(&mut block)); block.pre = true; } append(&mut block, &line.replace('\t', " "), style, link); } } else { append(&mut block, &text, style, link); } } Event::SoftBreak => append(&mut block, " ", style, link), Event::HardBreak => { flush(&mut page.blocks, &mut block); block.pre = pre; } Event::Rule => { flush(&mut page.blocks, &mut block); append(&mut block, "────────", style, None); flush(&mut page.blocks, &mut block); } Event::TaskListMarker(checked) => append( &mut block, if checked { "[x] " } else { "[ ] " }, style, None, ), _ => {} } } flush(&mut page.blocks, &mut block); page } } pub fn external(target: &str) -> bool { target.contains(':') || target.starts_with("//") } fn decode(text: &str) -> Result { let mut bytes = vec![]; let mut iter = text.bytes(); while let Some(c) = iter.next() { if c == b'%' { let hi = iter.next().and_then(|c| (c as char).to_digit(16)); let lo = iter.next().and_then(|c| (c as char).to_digit(16)); bytes.push( (hi.ok_or_else(|| anyhow!("Ungültige URL-Kodierung"))? * 16 + lo.ok_or_else(|| anyhow!("Ungültige URL-Kodierung"))?) as u8, ); } else { bytes.push(c); } } Ok(String::from_utf8(bytes)?) } #[derive(Debug)] pub struct Catalog { pub pages: Vec, } impl Catalog { pub fn embedded() -> &'static Self { static CATALOG: OnceLock = OnceLock::new(); CATALOG.get_or_init(|| Self::new(DOCUMENTS)) } pub fn new(documents: &[(&str, &str)]) -> Self { let mut pages: Vec<_> = documents.iter().map(|(p, md)| Page::parse(p, md)).collect(); let mut contents = String::from("# Contents\n\n"); let mut entries = vec![]; for p in &pages { contents.push_str(&format!("- [{}]({})\n", p.title, p.path)); entries.push((p.title.clone(), p.path.clone())); for h in p.headings.iter().skip(1) { let target = format!("{}#{}", p.path, h.anchor); contents.push_str(&format!( "{}- [{}]({target})\n", " ".repeat(h.level.saturating_sub(1)), h.title )); entries.push((format!("{} — {}", h.title, p.title), target)); } } entries.sort_by_key(|(label, _)| label.to_lowercase()); let index = format!( "# Index\n\n{}", entries .into_iter() .map(|(l, t)| format!("- [{l}]({t})\n")) .collect::() ); pages.push(Page::parse("contents", &contents)); pages.push(Page::parse("index", &index)); Self { pages } } pub fn resolve(&self, source: &str, target: &str) -> Result<(usize, usize)> { ensure!( !external(target), "Externes Ziel: {target} — wird nicht automatisch geöffnet" ); let (path, anchor) = target.split_once('#').unwrap_or((target, "")); let path = decode(path)?; ensure!( !path.starts_with('/') && !path.contains('\\') && !path.contains(':'), "Ziel außerhalb des Hilfekatalogs" ); let mut parts: Vec<&str> = if path.is_empty() { source.split('/').collect() } else { source .rsplit_once('/') .map(|(p, _)| p.split('/').collect()) .unwrap_or_default() }; for part in path.split('/').filter(|s| !s.is_empty() && *s != ".") { if part == ".." { ensure!(parts.pop().is_some(), "Ziel außerhalb des Hilfekatalogs"); } else { parts.push(part); } } let path = parts.join("/"); let page = self .pages .iter() .position(|p| p.path == path) .ok_or_else(|| anyhow!("Hilfedatei fehlt: {path}"))?; let anchor = decode(anchor)?; let block = if anchor.is_empty() { 0 } else { self.pages[page] .headings .iter() .find(|h| h.anchor == anchor) .ok_or_else(|| anyhow!("Hilfeanker fehlt: {path}#{anchor}"))? .block }; Ok((page, block)) } pub fn validate(&self) -> Result<()> { for page in &self.pages { for link in &page.links { if !external(&link.target) { self.resolve(&page.path, &link.target)?; } } } Ok(()) } pub fn search(&self, query: &str, only: Option<&str>) -> Vec<(usize, usize)> { let query = normalize(query); if query.is_empty() { return vec![]; } let mut result = vec![]; for (id, page) in self.pages.iter().enumerate().filter(|(_, p)| { p.path.starts_with("docs/") && only.is_none_or(|path| p.path == path) }) { for h in &page.headings { let end = page .headings .iter() .find(|next| next.block > h.block) .map_or(page.blocks.len(), |next| next.block); if page.blocks[h.block..end] .iter() .any(|b| words(&b.text()).contains(&query)) { result.push((id, h.block)); } } } result } } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Position { pub page: usize, pub block: usize, pub offset: usize, pub link: Option, pub horizontal: usize, pub query: Option, } #[derive(Default)] pub struct Help { pub position: Position, pub history: Vec, pub input: String, pub notice: String, pub return_focus: Option<(u64, bool, bool, bool)>, } #[derive(Debug)] pub struct Row { pub block: usize, pub start: usize, pub end: usize, } pub fn layout(page: &Page, width: usize) -> Vec { let width = width.max(1); let mut rows = vec![]; for (index, block) in page.blocks.iter().enumerate() { if block.pre || block.glyphs.is_empty() { rows.push(Row { block: index, start: 0, end: block.glyphs.len(), }); continue; } let mut start = 0; while start < block.glyphs.len() { let mut end = start; let mut cells = 0; let mut space = None; while end < block.glyphs.len() { let ch = block.glyphs[end].ch; let w = ch.width().unwrap_or(0); if cells + w > width && end > start { break; } cells += w; end += 1; if ch.is_whitespace() { space = Some(end); } } if end < block.glyphs.len() { if let Some(s) = space { end = s; } } rows.push(Row { block: index, start, end, }); start = end; } rows.push(Row { block: index, start: block.glyphs.len(), end: block.glyphs.len(), }); } rows } impl Help { pub fn page(&self) -> std::borrow::Cow<'_, Page> { let catalog = Catalog::embedded(); if let Some(query) = &self.position.query { let matches = catalog.search(query, None); let mut md = format!("# Index: {}\n\n", query.replace(['[', ']', '<', '>'], "")); if matches.is_empty() { md.push_str("Kein passendes Thema. Suche ändern oder im Index wählen.\n\n"); } for (p, b) in matches { let page = &catalog.pages[p]; let h = page.headings.iter().find(|h| h.block == b).unwrap(); md.push_str(&format!( "- [{} — {}]({}#{})\n", h.title, page.title, page.path, h.anchor )); } md.push_str("\n[Alphabetischer Index](index) · [Contents](contents)\n"); std::borrow::Cow::Owned(Page::parse("search", &md)) } else { std::borrow::Cow::Borrowed(&catalog.pages[self.position.page]) } } pub fn visit(&mut self, page: usize, block: usize, query: Option) { if self.history.len() == 20 { self.history.remove(0); } self.history.push(self.position.clone()); self.position = Position { page, block, query, ..Default::default() }; self.notice.clear(); self.input.clear(); } pub fn open(&mut self, target: &str) -> Result<()> { let source = self.page().path.clone(); let (page, block) = Catalog::embedded().resolve(&source, target)?; self.visit(page, block, None); Ok(()) } pub fn follow(&mut self) -> Result<()> { let target = self .page() .links .get( self.position .link .ok_or_else(|| anyhow!("Zuerst mit Tab einen Link wählen"))?, ) .ok_or_else(|| anyhow!("Link fehlt"))? .target .clone(); self.open(&target) } pub fn back(&mut self) { if let Some(p) = self.history.pop() { self.position = p; self.notice.clear(); self.input.clear(); } else { self.notice = "Keine ältere Hilfeseite; maximal 20 Rücksprünge".into(); } } pub fn next(&mut self) { let end = DOCUMENTS.len(); if self.position.page + 1 < end && self.position.query.is_none() { self.visit(self.position.page + 1, 0, None); } else if self.position.page >= end || self.position.query.is_some() { self.visit(0, 0, None); } else { self.notice = "Ende der Contents-Themenreihenfolge".into(); } } pub fn focus_link(&mut self, backwards: bool) { let page = self.page(); let n = page.links.len(); if n == 0 { return; } let id = match self.position.link { Some(i) => (i + n + if backwards { n - 1 } else { 1 }) % n, None => { if backwards { n - 1 } else { 0 } } }; let location = page.blocks.iter().enumerate().find_map(|(b, block)| { block .glyphs .iter() .position(|g| g.link == Some(id)) .map(|offset| (b, offset, block.pre)) }); self.position.link = Some(id); if let Some((b, o, pre)) = location { self.position.block = b; self.position.offset = o; self.position.horizontal = if pre { self.page().blocks[b].glyphs[..o] .iter() .map(|g| g.ch.width().unwrap_or(0)) .sum() } else { 0 }; } } pub fn scroll(&mut self, delta: isize, width: usize) { let rows = layout(&self.page(), width); if rows.is_empty() { return; } let current = self.row(&rows); let index = current.saturating_add_signed(delta).min(rows.len() - 1); self.position.block = rows[index].block; self.position.offset = rows[index].start; } fn row(&self, rows: &[Row]) -> usize { rows.iter() .position(|r| { r.block == self.position.block && r.start <= self.position.offset && (self.position.offset < r.end || r.start == r.end) }) .unwrap_or_else(|| { rows.iter() .position(|r| r.block >= self.position.block) .unwrap_or(0) }) } } impl App { fn help_show(&mut self) { if self .active_window() .is_none_or(|w| w.kind != WindowKind::Help) { self.help.return_focus = Some(( self.active, self.properties, self.value_focus, self.session.fullscreen, )); } self.properties = false; self.value_focus = false; self.session.fullscreen = false; self.show_tool(WindowKind::Help); } pub(crate) fn help_close(&mut self) { self.windows.retain(|w| w.kind != WindowKind::Help); if let Some((id, p, v, fullscreen)) = self.help.return_focus.take() { self.active = if self.windows.iter().any(|w| w.id == id) { id } else { self.windows.first().map_or(0, |w| w.id) }; self.properties = p; self.value_focus = v; self.session.fullscreen = fullscreen; } } pub fn help_context(&mut self, token: &str, class: Option<&str>) { let catalog = Catalog::embedded(); let matches = if let Some(class) = class { let page = catalog .pages .iter() .position(|p| p.path == "docs/forms-referenz.md") .unwrap(); let p = &catalog.pages[page]; let heading = p .headings .iter() .find(|h| words(&h.title).contains(&normalize(class))); heading .map(|h| { let end = p .headings .iter() .find(|next| next.block > h.block) .map_or(p.blocks.len(), |next| next.block); let block = (h.block..end) .find(|b| words(&p.blocks[*b].text()).contains(&normalize(token))) .unwrap_or(h.block); vec![(page, block)] }) .unwrap_or_default() } else { let library = catalog.search(token, Some("docs/bibliothek.md")); if !library.is_empty() { library } else { catalog.search(token, None) } }; self.help_show(); if matches.len() == 1 { self.help.visit(matches[0].0, matches[0].1, None); } else if token.is_empty() { self.help.visit(catalog.pages.len() - 1, 0, None); } else { self.help .visit(catalog.pages.len() - 1, 0, Some(token.into())); } } pub(crate) fn help_command(&mut self, command: Command) -> Result { use Command::*; let target = match command { HelpIndex => "index", HelpContents => "contents", Keyboard => "docs/tastatur.md", UsingHelp => "docs/hilfe.md", Tutorial => "docs/tutorial.md", HelpWindow => { self.help_show(); return Ok(true); } Topic => { if self .active_window() .is_some_and(|w| w.kind == WindowKind::Help) { self.help_context("", None); return Ok(true); } if self.mode == Mode::Designer { if let Ok((class, property)) = self.design_help_context() { self.help_context(&property, Some(&class)); return Ok(true); } } let token = self .editor_view() .ok() .and_then(|v| self.project.view(v).ok()) .and_then(|v| { self.project.document(v.document()).ok().map(|d| { token_at( self.editor .expansions .get(&self.editor_view().unwrap()) .map_or(d.code(), String::as_str), v.cursor, ) }) }) .unwrap_or_default(); self.help_context(&token, None); return Ok(true); } CloseWindow if self .active_window() .is_some_and(|w| w.kind == WindowKind::Help) => { self.help_close(); return Ok(true); } _ => return Ok(false), }; let (page, block) = Catalog::embedded().resolve("", target)?; self.help_show(); self.help.visit(page, block, None); Ok(true) } pub(crate) fn help_menu_context(&mut self, command: Command) { let target = command_target(command, self.mode == Mode::Designer); self.menu = None; self.control_menu = false; self.help_show(); match Catalog::embedded().resolve("", target) { Ok((p, b)) => self.help.visit(p, b, None), Err(e) => self.help.notice = e.to_string(), } } pub(crate) fn help_key(&mut self, key: KeyEvent) -> bool { if self .active_window() .is_none_or(|w| w.kind != WindowKind::Help) { return false; } let r = self.rect(self.active_window().unwrap()); let width = r.width.saturating_sub(2) as usize; let page = r.height.saturating_sub(4).max(1) as isize; match key.code { K::Esc => self.help_close(), K::F(1) if key.modifiers.contains(M::ALT) => self.help.back(), K::F(1) if key.modifiers.contains(M::CONTROL) => self.help.next(), K::Tab => self.help.focus_link(key.modifiers.contains(M::SHIFT)), K::BackTab => self.help.focus_link(true), K::Enter => { if self.help.input.is_empty() { if let Err(e) = self.help.follow() { self.help.notice = e.to_string(); } } else { let query = std::mem::take(&mut self.help.input); self.help .visit(Catalog::embedded().pages.len() - 1, 0, Some(query)); } } K::Up => self.help.scroll(-1, width), K::Down => self.help.scroll(1, width), K::PageUp => self.help.scroll(-page, width), K::PageDown => self.help.scroll(page, width), K::Home => { self.help.position.block = 0; self.help.position.offset = 0; } K::End => self.help.scroll(isize::MAX, width), K::Left if !key.modifiers.contains(M::CONTROL) => { self.help.position.horizontal = self.help.position.horizontal.saturating_sub(4) } K::Right if !key.modifiers.contains(M::CONTROL) => { self.help.position.horizontal = self.help.position.horizontal.saturating_add(4) } K::Backspace => { self.help.input.pop(); } K::Char(c) if !key.modifiers.intersects(M::ALT | M::CONTROL) => self.help.input.push(c), K::Delete => {} _ => return false, } true } pub(crate) fn help_render(&self, f: &mut Frame, area: Rect) { if area.width == 0 || area.height == 0 { return; } let page = self.help.page(); let rows = layout(&page, area.width as usize); let start = self.help.row(&rows); f.render_widget( Paragraph::new(format!("{} · Suche: {}", page.title, self.help.input)) .style(Style::default().fg(crate::render::dos(1))), Rect::new(area.x, area.y, area.width, 1), ); for (y, row) in rows .iter() .skip(start) .take(area.height.saturating_sub(2) as usize) .enumerate() { let block = &page.blocks[row.block]; let mut x = 0; let shift = if block.pre { self.help.position.horizontal } else { 0 }; let mut spans: Vec = vec![]; for g in &block.glyphs[row.start..row.end] { let w = g.ch.width().unwrap_or(0); let end = x + w; if x >= shift && end <= shift + area.width as usize { let mut style = g.style; if let Some(link) = g.link { style = style .fg(crate::render::dos(1)) .add_modifier(Modifier::UNDERLINED); if self.help.position.link == Some(link) { style = style.bg(crate::render::dos(1)).fg(crate::render::dos(15)); } } if let Some(last) = spans.last_mut().filter(|s| s.style == style) { last.content.to_mut().push(g.ch); } else { spans.push(Span::styled(g.ch.to_string(), style)); } } else if x < shift && end > shift { spans.push(Span::raw(" ".repeat(end - shift))); } x = end; } f.render_widget( Paragraph::new(Line::from(spans)), Rect::new(area.x, area.y + 1 + y as u16, area.width, 1), ); } if area.height > 1 { let status = if self.help.notice.is_empty() { "Tab: Link · Enter: folgen · Alt+F1: zurück · Esc: schließen" } else { &self.help.notice }; f.render_widget( Paragraph::new(status), Rect::new(area.x, area.bottom() - 1, area.width, 1), ); } } } pub fn token_at(code: &str, cursor: usize) -> String { let mut cursor = cursor.min(code.len()); while !code.is_char_boundary(cursor) { cursor -= 1; } let valid = |c: char| c.is_alphanumeric() || matches!(c, '_' | '$' | '%' | '&' | '!' | '#'); let start = code[..cursor] .char_indices() .rev() .take_while(|(_, c)| valid(*c)) .last() .map_or(cursor, |(i, _)| i); let end = code[cursor..] .char_indices() .find(|(_, c)| !valid(*c)) .map_or(code.len(), |(i, _)| cursor + i); code[start..end].into() } /// Context aliases for concrete command IDs; every target is checked with the catalog. pub fn command_target(command: Command, designer: bool) -> &'static str { use Command::*; match command { MakeExe | MakeLibrary => { "docs/ide-bedienung.md#native-exporte-ui-in-phase-5-erzeugung-in-phase-6" } NewForm | Form | Events | MenuBar | Grid | Palette | MenuDesign | Toolbox | Tool(_) => { "docs/ide-bedienung.md#formulare-gestalten" } Undo | Cut | Copy | Paste | Clear if designer => { "docs/ide-bedienung.md#formulare-gestalten" } Undo | Cut | Copy | Paste | Clear | Procedures | PreviousCode | NewSub | NewFunction | Code | IncludedFile | IncludedLines | Find | SelectedText | FindNext | Replace | LoadText | SaveText => "docs/ide-bedienung.md#editor-suche-und-quelltextpflege", Diagnostics | SyntaxChecking => "docs/ide-bedienung.md#übersetzung-und-revisionsbindung", Start | Restart | Continue | Pause | CommandLine | Startup | Output | OutputScreen | Shell => "docs/ide-bedienung.md#programme-ausführen", AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches | Trace | History | Breakpoint | ClearBreakpoints | BreakErrors | SetStatement | RunToCursor | Step | ProcedureStep | HistoryBack | HistoryForward | NextStatement | Calls | Debug | Immediate => "docs/ide-bedienung.md#debugger-und-direktfenster", Display | Paths | SaveOptions => "docs/ide-bedienung.md#display-und-benutzereinstellungen", RightMouse | Topic | UsingHelp | HelpIndex | HelpContents | HelpWindow | About => { "docs/hilfe.md" } Keyboard => "docs/tastatur.md", Tutorial | NewProject | NewModule => "docs/tutorial.md#projekt-und-formular-erstellen", OpenProject | SaveProject | AddFile | RemoveFile | SaveFile | SaveAs | Exit => { "docs/ide-bedienung.md#speichern-und-wechseln" } Print => "docs/ide-bedienung.md#programme-ausführen", Project | NewWindow | Arrange | NextWindow | PreviousWindow | CloseWindow | MoveWindow | SizeWindow | Minimize | Maximize | Restore | ControlMenu | FocusWindow(_) => { "docs/ide-bedienung.md#menüs-fenster-und-dialoge" } } }