diff --git a/Cargo.lock b/Cargo.lock index 721a83b..05d5f5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -328,6 +328,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "mio" version = "1.2.2" @@ -393,6 +399,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "memchr", + "unicase", +] + [[package]] name = "quote" version = "1.0.47" @@ -611,6 +628,7 @@ version = "0.1.0" dependencies = [ "anyhow", "crossterm", + "pulldown-cmark", "ratatui", "tb-frontend", "tb-runtime", @@ -676,6 +694,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/crates/tb-ide/Cargo.toml b/crates/tb-ide/Cargo.toml index 3a69fb0..4da6c0b 100644 --- a/crates/tb-ide/Cargo.toml +++ b/crates/tb-ide/Cargo.toml @@ -19,3 +19,4 @@ ratatui.workspace = true crossterm.workspace = true anyhow.workspace = true unicode-width.workspace = true +pulldown-cmark = { version = "0.13", default-features = false } diff --git a/crates/tb-ide/src/app.rs b/crates/tb-ide/src/app.rs index 4fd6dee..0210fd3 100644 --- a/crates/tb-ide/src/app.rs +++ b/crates/tb-ide/src/app.rs @@ -278,6 +278,7 @@ pub enum Hit { } pub struct App { + pub help: crate::help::Help, pub debugger: crate::debugger::Debugger, pub designer: crate::designer::Designer, pub session: crate::execution::Session, @@ -318,6 +319,7 @@ impl App { let (options, errors, config_disk) = Options::load(&config_path); project.include_paths = options.include_paths.clone(); let mut app = Self { + help: Default::default(), debugger: Default::default(), designer: Default::default(), session: Default::default(), @@ -480,9 +482,6 @@ impl App { .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()); @@ -575,6 +574,9 @@ impl App { } } fn action(&mut self, command: Command) -> Result<()> { + if self.help_command(command)? { + return Ok(()); + } if self.debug_command(command)? { return Ok(()); } @@ -1432,6 +1434,40 @@ impl App { } self.key(key); } else if let Event::Mouse(mouse) = event { + if mouse.kind == MouseEventKind::Down(MouseButton::Right) + && self.options.right_help + && !self.program_focus() + && self.dialog.is_none() + { + if let Some((m, i)) = self.menu { + if let Some((_, Some(command))) = self.menu_entries(m).get(i) { + self.help_menu_context(*command); + } + } else { + self.execute(Command::Topic); + } + return; + } + if self.dialog.is_none() + && self.menu.is_none() + && self + .active_window() + .is_some_and(|w| w.kind == WindowKind::Help) + { + let delta = match mouse.kind { + MouseEventKind::ScrollDown => 1, + MouseEventKind::ScrollUp => -1, + _ => 0, + }; + if delta != 0 { + let width = self + .rect(self.active_window().unwrap()) + .width + .saturating_sub(2); + self.help.scroll(delta, width as usize); + return; + } + } match self.design_mouse(mouse) { Ok(true) => return, Err(e) => { @@ -1622,6 +1658,26 @@ impl App { )) } fn key(&mut self, key: KeyEvent) { + if self.dialog.is_none() && key.code == K::F(1) { + if key.modifiers.is_empty() { + if let Some((m, i)) = self.menu { + if let Some((_, Some(command))) = self.menu_entries(m).get(i) { + self.help_menu_context(*command); + return; + } + } + } + if self.menu.is_none() { + if !self.help_key(key) { + self.execute(if key.modifiers.contains(M::SHIFT) { + Command::UsingHelp + } else { + Command::Topic + }); + } + return; + } + } if self.session.fullscreen && self.dialog.is_none() && key.code == K::F(4) { self.execute(Command::OutputScreen); return; @@ -1758,6 +1814,9 @@ impl App { } return; } + if self.help_key(key) { + return; + } if key.code == K::F(11) || matches!( key.code, diff --git a/crates/tb-ide/src/commands.rs b/crates/tb-ide/src/commands.rs index 8ab0029..6f403bd 100644 --- a/crates/tb-ide/src/commands.rs +++ b/crates/tb-ide/src/commands.rs @@ -97,15 +97,6 @@ pub enum Command { ControlMenu, FocusWindow(u64), } -impl Command { - pub fn feature_phase(self) -> Option { - use Command::*; - match self { - HelpIndex | HelpContents | Keyboard | Topic | UsingHelp | Tutorial => Some(7), - _ => None, - } - } -} #[derive(Debug, Clone)] pub struct Item { /// & markiert das sichtbare Mnemonic, … einen Dialog. diff --git a/crates/tb-ide/src/designer.rs b/crates/tb-ide/src/designer.rs index 888e208..eee3a36 100644 --- a/crates/tb-ide/src/designer.rs +++ b/crates/tb-ide/src/designer.rs @@ -1104,6 +1104,16 @@ impl App { } Ok(true) } + pub(crate) fn design_help_context(&mut self) -> Result<(String, String)> { + let node = self.selected_node()?; + let specs = forms::properties(node.class); + let property = if self.properties || self.value_focus { + specs.get(self.designer.property).map_or("", |p| p.name) + } else { + "" + }; + Ok((node.class.name().to_owned(), property.into())) + } pub(crate) fn design_value_focus(&mut self) -> Result<()> { let node = self.selected_node()?; let specs = forms::properties(node.class); diff --git a/crates/tb-ide/src/help.rs b/crates/tb-ide/src/help.rs new file mode 100644 index 0000000..03da65a --- /dev/null +++ b/crates/tb-ide/src/help.rs @@ -0,0 +1,972 @@ +//! 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::{Color, 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"), + ), + ("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(Color::Yellow); + } + 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(Color::Yellow)), + 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(Color::Cyan).add_modifier(Modifier::UNDERLINED); + if self.help.position.link == Some(link) { + style = style.bg(Color::Blue).fg(Color::White); + } + } + 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" + } + } +} diff --git a/crates/tb-ide/src/lib.rs b/crates/tb-ide/src/lib.rs index 57c5cd8..d6aab8d 100644 --- a/crates/tb-ide/src/lib.rs +++ b/crates/tb-ide/src/lib.rs @@ -14,3 +14,5 @@ pub mod execution; pub mod designer; pub mod debugger; + +pub mod help; diff --git a/crates/tb-ide/src/render.rs b/crates/tb-ide/src/render.rs index b1ffe40..b7fae18 100644 --- a/crates/tb-ide/src/render.rs +++ b/crates/tb-ide/src/render.rs @@ -413,15 +413,7 @@ impl App { kind @ (WindowKind::Calls | WindowKind::Debug | WindowKind::Immediate) => { self.debug_render(f, kind, inner) } - kind => put( - f, - inner, - match kind { - WindowKind::Help => "Help · Inhalte folgen in Change 07", - _ => "Debugger-Inhalte folgen in Change 06", - }, - content_style, - ), + WindowKind::Help => self.help_render(f, inner), } } if self.mode == Mode::Environment { diff --git a/crates/tb-ide/tests/help.rs b/crates/tb-ide/tests/help.rs new file mode 100644 index 0000000..bbcc525 --- /dev/null +++ b/crates/tb-ide/tests/help.rs @@ -0,0 +1,529 @@ +use crossterm::event::{ + Event, KeyCode as K, KeyEvent, KeyModifiers as M, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::{backend::TestBackend, layout::Rect, Terminal}; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, +}; +use tb_ide::{ + app::{App, Mode, WindowKind}, + commands::Command, + help::{layout, normalize, token_at, Catalog, Help, Page}, +}; +struct Temp(PathBuf); +impl Temp { + fn new() -> Self { + static N: AtomicUsize = AtomicUsize::new(0); + let p = std::env::temp_dir().join(format!( + "tb-help-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&p).unwrap(); + Self(p.canonicalize().unwrap()) + } + fn app(&self) -> App { + App::new(&self.0, self.0.join("options"), (100, 30)).unwrap() + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn key(a: &mut App, k: K, m: M) { + a.handle(Event::Key(KeyEvent::new(k, m))); +} +fn draw(a: &mut App) -> String { + let mut t = Terminal::new(TestBackend::new(a.size.0, a.size.1)).unwrap(); + t.draw(|f| a.render(f)).unwrap(); + t.backend() + .buffer() + .content + .chunks(a.size.0 as usize) + .map(|r| r.iter().map(|c| c.symbol()).collect::()) + .collect::>() + .join("\n") +} +fn menu(a: &mut App, c: Command) { + let (m, i) = a + .menus() + .iter() + .enumerate() + .find_map(|(m, menu)| { + menu.items + .iter() + .position(|i| i.command == Some(c)) + .map(|i| (m, i)) + }) + .unwrap(); + a.menu = Some((m, i)); + key(a, K::Enter, M::NONE); + assert_eq!(a.last_command, Some(c)); +} +#[test] +fn catalog_links_and_missing_targets_are_checked() { + let c = Catalog::embedded(); + c.validate().unwrap(); + for p in &c.pages { + for h in &p.headings { + let (_, b) = c.resolve("", &format!("{}#{}", p.path, h.anchor)).unwrap(); + assert_eq!(b, h.block); + } + } + let duplicates = Catalog::new(&[("a.md", "# Titel\n\n## Titel\n\n## Titel-1\n\n## Titel\n")]); + for h in &duplicates.pages[0].headings { + assert_eq!( + duplicates + .resolve("", &format!("a.md#{}", h.anchor)) + .unwrap() + .1, + h.block + ); + } + let bad = Catalog::new(&[("docs/a.md", "# A\n\n[broken](missing.md)")]); + assert!(bad.validate().unwrap_err().to_string().contains("fehlt")); + let bad = Catalog::new(&[("docs/a.md", "# A\n\n[broken](#missing)")]); + assert!(bad.validate().is_err()); + for target in [ + "../../secret", + "%2e%2e/%2e%2e/secret", + "/etc/passwd", + "file:///etc/passwd", + "../missing.md", + "hilfe.md#missing", + "hilfe.md#%GG", + "C:%5csecret", + ] { + assert!(c.resolve("docs/hilfe.md", target).is_err(), "{target}"); + } + assert!(c.resolve("docs/hilfe.md", "../PLAN.md").is_ok()); + let t = Temp::new(); + let mut a = t.app(); + a.execute(Command::UsingHelp); + let before = a.help.position.clone(); + assert!(a + .help + .open("https://example.org/private") + .unwrap_err() + .to_string() + .contains("Extern")); + assert_eq!(a.help.position, before); +} +#[test] +fn markdown_blocks_escapes_links_and_unicode_layout() { + let p=Page::parse("sample","# Überschrift\n\nAbsatz mit **fett**, *kursiv*, \\*Escape\\* und [Link](#überschrift).\n\n1. Eins\n2. Zwei\n\n```basic\nPRINT \"界ä\"\n012345678901234567890123456789\n```\n\n| Spalte | Breit |\n|---|---|\n| Wert | 界界界界 |\n"); + let text = p + .blocks + .iter() + .map(|b| b.text()) + .collect::>() + .join("\n"); + assert!(text.contains("*Escape*")); + assert!(text.contains("◄Link►")); + assert!(text.contains("1. Eins")); + assert!(text.contains("2. Zwei")); + assert!(p.blocks.iter().any(|b| b.pre && b.text().contains("PRINT"))); + assert!(p.blocks.iter().any(|b| b.pre && b.text().contains("界界"))); + assert_eq!(p.headings[0].anchor, "überschrift"); + for width in [1, 7, 20, 80] { + let rows = layout(&p, width); + for (i, b) in p.blocks.iter().enumerate() { + let reconstructed: String = rows + .iter() + .filter(|r| r.block == i) + .flat_map(|r| b.glyphs[r.start..r.end].iter().map(|g| g.ch)) + .collect(); + assert_eq!(reconstructed, b.text()); + } + } +} +#[test] +fn offline_help_child() { + if std::env::var_os("TB_HELP_OFFLINE_CHILD").is_none() { + return; + } + let base = std::env::current_dir().unwrap(); + assert!(!base.join("docs").exists()); + let mut a = App::new(&base, base.join("options"), (100, 30)).unwrap(); + for command in [ + Command::HelpIndex, + Command::HelpContents, + Command::Keyboard, + Command::UsingHelp, + Command::Tutorial, + ] { + a.execute(command); + assert_eq!(a.active_window().unwrap().kind, WindowKind::Help); + assert!(!draw(&mut a).is_empty()); + } + for path in [ + "docs/sprachreferenz.md", + "docs/bibliothek.md", + "docs/forms-referenz.md", + "docs/ide-bedienung.md", + "docs/tutorial.md", + ] { + let (p, b) = Catalog::embedded().resolve("", path).unwrap(); + a.help.visit(p, b, None); + assert!(a.help.page().blocks.len() > 10); + assert!(draw(&mut a).contains(&a.help.page().title)); + } +} +#[test] +fn help_works_from_a_directory_without_checkout() { + let t = Temp::new(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .current_dir(&t.0) + .env("TB_HELP_OFFLINE_CHILD", "1") + .args(["--exact", "offline_help_child", "--nocapture"]) + .status() + .unwrap(); + assert!(status.success()); +} +#[test] +fn context_functions_properties_commands_and_unknown_queries() { + assert_eq!(normalize("lEfT$"), "LEFT"); + assert_eq!(token_at("PRINT LEFT$(x$, 2)", 8), "LEFT$"); + let t = Temp::new(); + let mut a = t.app(); + let id = a.active_document().unwrap(); + a.project + .replace_text(id, 0..0, "PRINT lEfT$(\"abc\", 2)") + .unwrap(); + let view = match a.active_window().unwrap().kind { + WindowKind::Code(v) => v, + _ => panic!(), + }; + a.project.view_mut(view).unwrap().cursor = 9; + key(&mut a, K::F(1), M::NONE); + assert_eq!(a.help.page().path, "docs/bibliothek.md"); + assert!(a.help.page().blocks[a.help.position.block] + .text() + .contains("Strings")); + key(&mut a, K::Esc, M::NONE); + a.execute(Command::NewForm); + assert_eq!(a.mode, Mode::Designer); + let specs = tb_frontend::forms::properties(tb_frontend::forms::ObjectClass::Form); + a.designer.property = specs.iter().position(|p| p.name == "CAPTION").unwrap(); + a.properties = true; + let previous = a.active; + key(&mut a, K::F(2), M::NONE); + key(&mut a, K::F(1), M::NONE); + assert_eq!(a.help.page().path, "docs/forms-referenz.md"); + assert!(a.help.page().blocks[a.help.position.block] + .text() + .contains("Caption")); + assert!(draw(&mut a).contains("Caption")); + key(&mut a, K::Esc, M::NONE); + assert_eq!(a.active, previous); + assert!(a.properties && a.value_focus); + a.value_focus = false; + a.properties = false; + let (m, i) = a + .menus() + .iter() + .enumerate() + .find_map(|(m, menu)| { + menu.items + .iter() + .position(|i| i.command == Some(Command::SaveProject)) + .map(|i| (m, i)) + }) + .unwrap(); + a.menu = Some((m, i)); + key(&mut a, K::F(1), M::NONE); + assert!(a.help.page().blocks[a.help.position.block] + .text() + .contains("Speichern")); + a.help_context("unbekanntxyz", None); + assert!(draw(&mut a).contains("unbekanntxyz")); + assert!(draw(&mut a).contains("Kein passendes Thema")); + a.help_context("PRINT", None); + assert!(a.help.position.query.is_some()); + assert!(a.help.page().links.len() > 2); +} +#[test] +fn links_history_resize_scroll_and_focus_use_real_events() { + let t = Temp::new(); + let mut a = t.app(); + let previous = a.active; + menu(&mut a, Command::HelpContents); + let help_id = a.active; + key(&mut a, K::Tab, M::NONE); + let first = a.help.position.link; + key(&mut a, K::Tab, M::NONE); + assert_ne!(a.help.position.link, first); + key(&mut a, K::BackTab, M::SHIFT); + assert_eq!(a.help.position.link, first); + key(&mut a, K::Down, M::NONE); + let before = a.help.position.clone(); + key(&mut a, K::Enter, M::NONE); + assert_ne!(a.help.position.page, before.page); + key(&mut a, K::F(1), M::ALT); + assert_eq!(a.help.position, before); + let w = a.windows.iter_mut().find(|w| w.id == help_id).unwrap(); + w.normal = Rect::new(2, 2, 28, 18); + let small = draw(&mut a); + assert!(small.contains("Contents")); + let pos = a.help.position.clone(); + a.handle(Event::Resize(120, 40)); + a.windows + .iter_mut() + .find(|w| w.id == help_id) + .unwrap() + .normal + .width = 85; + draw(&mut a); + assert_eq!(a.help.position, pos); + let (p, b) = Catalog::embedded().resolve("", "docs/tutorial.md").unwrap(); + a.help.visit(p, b, None); + key(&mut a, K::PageDown, M::NONE); + assert!(a.help.position.block > 0); + key(&mut a, K::Home, M::NONE); + assert_eq!(a.help.position.block, 0); + key(&mut a, K::Esc, M::NONE); + assert_eq!(a.active, previous); + assert!(!a.windows.iter().any(|w| w.kind == WindowKind::Help)); +} +#[test] +fn twenty_back_steps_end_of_contents_and_all_entries() { + let mut h = Help::default(); + let mut states = vec![]; + for i in 0..25 { + states.push(h.position.clone()); + h.visit(i % 12, i, None); + } + assert_eq!(h.history.len(), 20); + for expected in states.into_iter().rev().take(20) { + h.back(); + assert_eq!(h.position, expected); + } + let p = h.position.clone(); + h.back(); + assert_eq!(h.position, p); + assert!(h.notice.contains("20")); + h.visit(11, 0, None); + let p = h.position.clone(); + h.next(); + assert_eq!(h.position, p); + assert!(h.notice.contains("Ende")); + let t = Temp::new(); + let mut a = t.app(); + for (c, path) in [ + (Command::HelpIndex, "index"), + (Command::HelpContents, "contents"), + (Command::Keyboard, "docs/tastatur.md"), + (Command::UsingHelp, "docs/hilfe.md"), + (Command::Tutorial, "docs/tutorial.md"), + ] { + menu(&mut a, c); + assert_eq!(a.help.page().path, path); + assert!(draw(&mut a).contains(&a.help.page().title)); + } + key(&mut a, K::F(1), M::SHIFT); + assert_eq!(a.help.page().path, "docs/hilfe.md"); + key(&mut a, K::F(1), M::CONTROL); + assert_eq!(a.help.page().path, "docs/tutorial.md"); + menu(&mut a, Command::About); + let title = &a.dialog.as_ref().unwrap().title; + assert!(title.contains(env!("CARGO_PKG_VERSION"))); + assert!(title.contains(env!("CARGO_PKG_AUTHORS"))); + assert!(title.contains("Copyright")); +} +#[test] +fn right_click_configuration_and_external_link_follow() { + let t = Temp::new(); + let mut a = t.app(); + let event = Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 2, + row: 3, + modifiers: M::NONE, + }); + a.options.right_help = false; + a.handle(event.clone()); + assert_ne!(a.active_window().unwrap().kind, WindowKind::Help); + a.options.right_help = true; + a.handle(event); + assert_eq!(a.active_window().unwrap().kind, WindowKind::Help); + let (p, b) = Catalog::embedded() + .resolve("", "docs/dateiformate.md") + .unwrap(); + a.help.visit(p, b, None); + let link = a + .help + .page() + .links + .iter() + .position(|l| l.target.starts_with("https:")) + .unwrap(); + for _ in 0..=link { + key(&mut a, K::Tab, M::NONE); + } + assert!(draw(&mut a).contains("extern")); + let before = a.help.position.clone(); + key(&mut a, K::Enter, M::NONE); + assert_eq!(a.help.position, before); + assert!(a.help.notice.contains("nicht automatisch")); + assert!(!a.session.file_shell); + assert!(a.session.host.shell_request.is_none()); +} + +#[test] +fn every_command_and_forms_alias_resolves_and_invalid_alias_fails() { + use tb_frontend::forms::ObjectClass; + let c = Catalog::embedded(); + for designer in [false, true] { + for menu in tb_ide::commands::menus(designer) { + for command in menu.items.iter().filter_map(|i| i.command) { + c.resolve("", tb_ide::help::command_target(command, designer)) + .unwrap(); + } + } + } + let t = Temp::new(); + let mut a = t.app(); + for class in ObjectClass::ALL { + for property in tb_frontend::forms::properties(class) { + a.help_context(property.name, Some(class.name())); + assert!( + a.help.position.query.is_none(), + "{}.{}", + class.name(), + property.name + ); + assert_eq!(a.help.page().path, "docs/forms-referenz.md"); + let page = a.help.page(); + let heading = page + .headings + .iter() + .rev() + .find(|h| h.block <= a.help.position.block) + .unwrap(); + assert!(heading.title.to_uppercase().contains(class.name())); + } + } + // The same resolver validates command aliases; a stale anchor is a hard failure. + assert!(c + .resolve( + "", + "docs/ide-bedienung.md#absichtlich-fehlender-befehlsanker" + ) + .is_err()); +} + +#[test] +fn code_and_tables_remain_reachable_after_resize_and_horizontal_scroll() { + let t = Temp::new(); + let mut a = t.app(); + a.execute(Command::HelpContents); + a.windows + .iter_mut() + .find(|w| w.id == a.active) + .unwrap() + .normal = Rect::new(2, 2, 22, 18); + let (p, _) = Catalog::embedded().resolve("", "docs/tutorial.md").unwrap(); + let code = Catalog::embedded().pages[p] + .blocks + .iter() + .position(|b| b.pre && b.text().contains("Hallo aus")) + .unwrap(); + a.help.visit(p, code, None); + let before = a.help.position.clone(); + let small = draw(&mut a); + assert!(small.contains("PRINT")); + for _ in 0..3 { + key(&mut a, K::Right, M::NONE); + } + assert!(draw(&mut a).contains("Formular")); + let after_scroll = a.help.position.clone(); + a.handle(Event::Resize(110, 35)); + a.windows + .iter_mut() + .find(|w| w.id == a.active) + .unwrap() + .normal + .width = 70; + draw(&mut a); + assert_eq!(a.help.position, after_scroll); + for _ in 0..3 { + key(&mut a, K::Left, M::NONE); + } + assert_eq!(a.help.position, before); + assert!(draw(&mut a).contains("Hallo aus dem Formular")); + let (p, _) = Catalog::embedded() + .resolve("", "docs/ide-bedienung.md") + .unwrap(); + let row = Catalog::embedded().pages[p] + .blocks + .iter() + .position(|b| b.pre && b.text().contains("Index, Contents")) + .unwrap(); + a.help.visit(p, row, None); + a.windows + .iter_mut() + .find(|w| w.id == a.active) + .unwrap() + .normal + .width = 22; + let initial = draw(&mut a); + assert!(!initial.contains("Using Help")); + let mut found = false; + for _ in 0..20 { + key(&mut a, K::Right, M::NONE); + found |= draw(&mut a).contains("Using Help"); + } + assert!(found); + // Real Unicode paragraphs wrap and remain at the identical logical anchor. + a.execute(Command::UsingHelp); + key(&mut a, K::PageDown, M::NONE); + let pos = a.help.position.clone(); + let narrow = draw(&mut a); + assert!(!narrow.contains('\u{fffd}')); + a.windows + .iter_mut() + .find(|w| w.id == a.active) + .unwrap() + .normal + .width = 90; + draw(&mut a); + assert_eq!(a.help.position, pos); +} + +#[test] +fn help_preserves_fullscreen_and_does_not_modify_designer_or_source() { + let t = Temp::new(); + let mut a = t.app(); + a.execute(Command::Output); + a.session.fullscreen = true; + let original = a.active; + key(&mut a, K::F(1), M::SHIFT); + assert!(!a.session.fullscreen); + assert!(draw(&mut a).contains("Hilfe benutzen")); + key(&mut a, K::Esc, M::NONE); + assert_eq!(a.active, original); + assert!(a.session.fullscreen); + a.session.fullscreen = false; + a.execute(Command::NewForm); + let doc = a.designer.document.unwrap(); + let before = a.project.document(doc).unwrap().revision(); + key(&mut a, K::F(1), M::SHIFT); + for k in [K::Down, K::Right, K::Tab, K::PageDown, K::Delete] { + key(&mut a, k, M::NONE); + } + assert_eq!(a.project.document(doc).unwrap().revision(), before); + for ch in "LEFT$".chars() { + key(&mut a, K::Char(ch), M::NONE); + } + key(&mut a, K::Enter, M::NONE); + assert!(a.help.position.query.as_deref() == Some("LEFT$")); + key(&mut a, K::Tab, M::NONE); + key(&mut a, K::Enter, M::NONE); + assert!(a.help.position.query.is_none()); + key(&mut a, K::F(1), M::ALT); + assert_eq!(a.help.position.query.as_deref(), Some("LEFT$")); + assert_eq!(a.project.document(doc).unwrap().revision(), before); +} diff --git a/docs/forms-referenz.md b/docs/forms-referenz.md index d8e3266..e52c0fd 100644 --- a/docs/forms-referenz.md +++ b/docs/forms-referenz.md @@ -32,7 +32,9 @@ Timer, VScrollBar` — alle außer `Menu` über die Toolbox platzierbar ## Eigenschaften/Methoden/Ereignisse je Steuerelement -**Form** — Ereignisse: Click, DblClick, DragDrop, DragOver, GotFocus, +### Form + +Ereignisse: Click, DblClick, DragDrop, DragOver, GotFocus, KeyDown, KeyPress, KeyUp, Load, LostFocus, MouseDown, MouseMove, MouseUp, Paint, Resize, Unload. Methoden: CLS, DRAG, HIDE, LOAD, MOVE, PRINT, PRINTFORM, REFRESH, SHOW, TEXTHEIGHT, TEXTWIDTH, UNLOAD. Eigenschaften: @@ -41,27 +43,35 @@ DragMode, Enabled, ForeColor, FormName, FormType, Height, Left, MaxButton, MinButton, MousePointer, Parent, ScaleHeight, ScaleWidth, Tag, Top, Visible, Width, WindowState. -**CommandButton** — Ereignisse: Click, DragDrop, DragOver, GotFocus, +### CommandButton + +Ereignisse: Click, DragDrop, DragOver, GotFocus, KeyDown, KeyPress, KeyUp, LostFocus. Methoden: DRAG, MOVE, REFRESH, SETFOCUS. Eigenschaften: BackColor, Cancel, Caption, CtlName, Default, DragMode, Enabled, Height, Index, Left, MousePointer, Parent, TabIndex, TabStop, Tag, Top, Value, Visible, Width. Darstellung nach Höhe: 1 = ``, 2 = einzeiliger Rahmen, ≥3 = Kasten; Mindestgröße 1×3. -**TextBox** — Ereignisse: Change, DragDrop, DragOver, GotFocus, KeyDown, +### TextBox + +Ereignisse: Change, DragDrop, DragOver, GotFocus, KeyDown, KeyPress, KeyUp, LostFocus. Methoden: DRAG, MOVE, REFRESH, SETFOCUS. Eigenschaften: BackColor, BorderStyle, CtlName, DragMode, Enabled, ForeColor, Height, Index, Left, MousePointer, MultiLine, Parent, ScrollBars, SelLength, SelStart, SelText, TabIndex, TabStop, Tag, Text, Top, Visible, Width. -**ListBox** — Ereignisse: Click, DblClick, DragDrop, DragOver, GotFocus, +### ListBox + +Ereignisse: Click, DblClick, DragDrop, DragOver, GotFocus, Key*, LostFocus, Mouse*. Methoden: ADDITEM, DRAG, MOVE, REFRESH, REMOVEITEM, SETFOCUS. Eigenschaften: BackColor, CtlName, DragMode, Enabled, ForeColor, Height, Index, Left, List, ListCount, ListIndex, MousePointer, Parent, Sorted, TabIndex, TabStop, Tag, Text, Top, Visible, Width. -**ComboBox** — wie ListBox plus Change, DropDown (Ereignisse), SelLength/ +### ComboBox + +wie ListBox plus Change, DropDown (Ereignisse), SelLength/ SelStart/SelText/Style/Text (Eigenschaften). `ListIndex = -1` hebt die Auswahl auch bei leerer Liste auf. ADDITEM vor dem @@ -77,37 +87,51 @@ Reihenfolge; die Auswahl bleibt beim selben Eintrag, auch bei Duplikaten. Die Simple-ComboBox zeigt unter dem Eingabefeld ihre Listeneinträge samt Auswahlmarkierung; Inhalt wird auf den verfügbaren Listenbereich begrenzt. -**CheckBox / OptionButton** — Ereignisse: Click (Option auch DblClick), +### CheckBox / OptionButton + +Ereignisse: Click (Option auch DblClick), Drag*, GotFocus, Key*, LostFocus. Methoden: DRAG, MOVE, REFRESH, SETFOCUS. Eigenschaften: BackColor, Caption, CtlName, DragMode, Enabled, ForeColor, Height, Index, Left, MousePointer, Parent, TabIndex, TabStop, Tag, Top, Value, Visible, Width. -**Frame** — nur DragDrop/DragOver; DRAG, MOVE, REFRESH (kein SetFocus); +### Frame + +nur DragDrop/DragOver; DRAG, MOVE, REFRESH (kein SetFocus); Container für Gruppierung (OptionButton-Gruppen). `Index` und `TabIndex` werden in den Originaldateien auch für Frames gespeichert. -**Label** — Ereignisse: Change, Click, DblClick, Drag*, Mouse*. Methoden: +### Label + +Ereignisse: Change, Click, DblClick, Drag*, Mouse*. Methoden: DRAG, MOVE, REFRESH. Eigenschaften: zusätzlich Index, TabIndex, Alignment, AutoSize, BorderStyle. -**HScrollBar/VScrollBar** — Ereignisse: Change, Drag*, GotFocus, Key*, +### HScrollBar/VScrollBar + +Ereignisse: Change, Drag*, GotFocus, Key*, LostFocus (**kein** separates Scroll-Ereignis). Eigenschaften: zusätzlich Attached (VBDOS-Spezifikum: am Formularrand angedockt; Laufzeit read-only), LargeChange, Min, Max, SmallChange, Value. -**Spin** — Ereignisse: Custom(EventType), Drag*, GotFocus, Key*, LostFocus. +### Spin + +Ereignisse: Custom(EventType), Drag*, GotFocus, Key*, LostFocus. `Style` wählt vertikal/horizontal, `Interval` steuert die Wiederholung; `Value` läuft zyklisch zwischen `Min` und `Max`. `BorderStyle`, `Width` und `Height` sind zur Laufzeit schreibgeschützt. -**PictureBox** — Text-Zeichenfläche (PRINT/CLS aufs Control) und Container +### PictureBox + +Text-Zeichenfläche (PRINT/CLS aufs Control) und Container für OptionButton-Gruppen. Ereignisse: Click, DblClick, Drag*, GotFocus, Key*, LostFocus, Mouse*, Paint. Methoden: CLS, DRAG, MOVE, PRINT, REFRESH, SETFOCUS, TEXTHEIGHT, TEXTWIDTH. Eigenschaften: zusätzlich Index, TabIndex, TabStop, AutoRedraw, CurrentX, CurrentY, ScaleHeight, ScaleWidth. -**Timer** — Ereignis Timer, keine Methoden; Eigenschaften: CtlName, Enabled, +### Timer + +Ereignis Timer, keine Methoden; Eigenschaften: CtlName, Enabled, Index, Interval (0 = aus … 65 535 ms), Parent, Tag. Die erste Frist beginnt mit der Aktivierung auf einem sichtbaren Formular @@ -125,10 +149,14 @@ neu beginnenden aktiven Timern ausgewertet. Die VM erledigt dies nach Property-Zuweisungen, SHOW und dem Laden von Arrayelementen sowie beim Übernehmen eines vorbereiteten Modells; es gibt keinen globalen Zeitcache. -**Menu** — Ereignis Click; Eigenschaften Caption, Checked, CtlName, Enabled, +### Menu + +Ereignis Click; Eigenschaften Caption, Checked, CtlName, Enabled, Index, Parent, Separator, Shortcut, Tag, Visible. -**DirListBox/DriveListBox/FileListBox** — Datei-Browser-Controls +### DirListBox/DriveListBox/FileListBox + +Datei-Browser-Controls (Path/Drive/FileName/Pattern/Archive/Hidden/Normal/ReadOnly/System; Ereignisse u. a. Change, PathChange, PatternChange). Terminal Basic bildet sie plattformneutral nach (Laufwerksliste → Wurzeln/Mounts). diff --git a/docs/hilfe.md b/docs/hilfe.md new file mode 100644 index 0000000..9188798 --- /dev/null +++ b/docs/hilfe.md @@ -0,0 +1,41 @@ +# Hilfe benutzen + +Die Hilfe ist im Programm eingebettet und braucht weder Internet noch ein +Arbeitsverzeichnis mit Dokumentation. Help → Contents zeigt die Referenzen +mit ihren Abschnitten in Lesereihenfolge; Help → Index ordnet sie alphabetisch. + +## Kontext und Suche + +F1 oder Help → Topic öffnet die Hilfe zum BASIC-Wort unter dem Cursor, +zur gewählten Designerklasse beziehungsweise Eigenschaft oder zum markierten +Menübefehl. Großschreibung und BASIC-Typsuffixe werden berücksichtigt. +Mehrere Treffer erscheinen als auswählbare Links. Ohne Treffer bleibt das +Suchwort im Index sichtbar. Im Hilfefenster kann ein Suchwort direkt eingegeben +und mit Enter gesucht werden; Backspace korrigiert es. +Options → Right Mouse aktiviert dieselbe Kontext-Hilfe per Rechtsklick. + +## Navigation + +Interne Links erscheinen als ◄Thema►. Tab und Shift+Tab wählen den nächsten +beziehungsweise vorherigen Link; Enter folgt dem markierten Link. Alt+F1 +kehrt zu einem der letzten 20 Besuchszustände zurück, einschließlich +Leseposition und Linkfokus. Ältere Zustände werden verdrängt. Ctrl+F1 öffnet +die nächste Datei in der Contents-Reihenfolge. Am Ende erscheint ein Hinweis. + +Pfeil auf/ab und Page Up/Down scrollen, Home/End springen zum Anfang/Ende. +Pfeil links/rechts verschiebt breite Tabellen und Code horizontal. +Fließtext wird an die Breite angepasst; eine Größenänderung erhält den +logischen Leseanker und den markierten Link. Bei sehr schmaler Ansicht +können breite Unicode-Zeichen horizontal gescrollt werden. +Esc schließt die Hilfe und stellt den vorherigen Fenster- und Property-Fokus +wieder her. Die allgemeinen Fensterbefehle bleiben verfügbar. + +## Linkziele + +Externe Links tragen zusätzlich „extern“ und zeigen ihre URL. Enter zeigt +nur einen Hinweis: Die Hilfe startet keinen Browser, Prozess oder Download. +Fehlende Dateien oder Anker erzeugen eine Diagnose. Es werden ausschließlich +mitgelieferte Dokumente angezeigt, keine beliebigen lokalen Dateien. + +Weiter: [Tastatur](tastatur.md), [Tutorial](tutorial.md), +[IDE-Bedienung](ide-bedienung.md). diff --git a/docs/ide-bedienung.md b/docs/ide-bedienung.md index d266c2d..3f4346d 100644 --- a/docs/ide-bedienung.md +++ b/docs/ide-bedienung.md @@ -127,11 +127,11 @@ Library-Symbole/Aufrufvertrag und der native Verbraucher-Nachweis stehen explizit in [PLAN.md, Phase 6](../PLAN.md). Ein umbenanntes TBC erfüllt keines der beiden Exportziele. -## Befehlsabdeckung nach Change 06 +## Befehlsabdeckung nach Change 07 -Alle Referenzbefehle sind in ihrer Menüfolge vorhanden. Fachbefehle der -folgenden Changes sind sichtbar deaktiviert und zeigen einen -Verfügbarkeitsgrund; ihre Kürzel gelangen nicht versehentlich an BASIC. +Alle Referenzbefehle sind in ihrer Menüfolge vorhanden und angebunden. +Nicht verfügbare Aktionen zeigen einen zustandsabhängigen Grund; ihre +Kürzel gelangen nicht versehentlich an BASIC. Ctrl+C im Editor ist Kopieren, bei laufendem Programm mit Output-Fokus unterbricht Ctrl+C sofort die Sitzung, ohne eine BASIC-Taste einzureihen. @@ -143,12 +143,12 @@ Output-Fokus unterbricht Ctrl+C sofort die Sitzung, ohne eine BASIC-Taste einzur | Search | Find, Selected Text, Repeat Last Find, Change | — | | Run | Start/Restart/Continue/Pause, COMMAND$, wirksame Startdatei und beide Make-Dialoge | Native Erzeugung: Phase 6 | | Debug | Breakpoints, Schritte, Calls, Watches/Watchpoints, Direktfenster, Trace/History, Break on Errors und Set Next Statement | — | -| Options | Display, Set Paths, Right Mouse, Save, Syntax Checking einschließlich Zeilenprüfung | Kontext-Hilfe: 07 | -| Window | New Window, Arrange All, Calls/Debug/Help/Immediate/Output/Project, aktive Codeliste, alle Fensterkürzel | Hilfeinhalt: 07 | +| Options | Display, Set Paths, Right Mouse, Save, Syntax Checking einschließlich Zeilenprüfung | — | +| Window | New Window, Arrange All, Calls/Debug/Help/Immediate/Output/Project, aktive Codeliste, alle Fensterkürzel | — | | Designer Tools/Window | Vollständige Toolbox einschließlich Spin, Formliste, Color Palette, Menu Design Window | — | -| Help | Vollständiges Menü, About und Help-Fensterrahmen | Index, Contents, Keyboard, Topic, Using Help, Tutorial: 07 | +| Help | Index, Contents, Keyboard, Topic, Using Help, Tutorial, About | — | -Editor, inkrementelle Übersetzung, Ausführung, Formulardesigner und Debugger sind umgesetzt. Die gesamte +Editor, inkrementelle Übersetzung, Ausführung, Formulardesigner, Debugger und Hilfe sind umgesetzt. Die gesamte Phase-5-Abnahme erfolgt in Change 08. ## Programme ausführen @@ -469,3 +469,19 @@ Cursorziele setzen den unveränderten Quellstand der pausierten Sitzung voraus; nach Änderungen zuerst neu starten. F5 kann weiterhin nach ausdrücklicher Auswahl das gekennzeichnete alte Kompilat fortsetzen; seine Watches verwenden die ursprünglichen Symbole und Slots. + + +## Offline-Hilfe + +Help → Contents zeigt die eingebetteten Dokumente mit ihren Abschnitten, +Help → Index dieselben Ziele alphabetisch. F1 öffnet den Kontext zum +BASIC-Wort, zur Designerklasse/-eigenschaft oder zum markierten Menübefehl. +Shift+F1 erklärt die [Hilfebedienung](hilfe.md); [Tastatur](tastatur.md) und +[Tutorial](tutorial.md) beschreiben die implementierten Arbeitsabläufe. + +Tab/Shift+Tab wählen Links, Enter folgt, Alt+F1 stellt bis zu 20 vorherige +Lesepositionen wieder her. Ctrl+F1 folgt der Contents-Dateireihenfolge. +Pfeile und Page Up/Down scrollen; links/rechts verschiebt breite Code- und +Tabellenzeilen. Größenänderungen behalten Thema, Leseanker und Linkfokus. +Esc kehrt zum vorherigen Fenster zurück. Die Hilfe braucht keinen Checkout +und öffnet externe Links ausschließlich als sichtbaren Hinweis. diff --git a/docs/tastatur.md b/docs/tastatur.md new file mode 100644 index 0000000..610961b --- /dev/null +++ b/docs/tastatur.md @@ -0,0 +1,46 @@ +# Tastatur der IDE + +## Allgemeine Bedienung + +| Taste | Aktion | +| --- | --- | +| Alt+Buchstabe, F11 | Menü öffnen; Pfeile wählen, Enter bestätigt, Esc schließt | +| F6 / Shift+F6 | Nächstes / vorheriges Fenster | +| Ctrl+F4 | Aktives Fenster schließen | +| Ctrl+F7 / Ctrl+F8 | Fenster verschieben / Größe ändern; Pfeile, Enter, Esc | +| Ctrl+F9 / Ctrl+F10 / Ctrl+F5 | Minimieren / maximieren / wiederherstellen | +| F1 / Shift+F1 | Kontext-Hilfe / Hilfe benutzen | +| F2 / Shift+F12 | Code / Formular | +| F12 | Ereignisprozeduren | +| Ctrl+Z / Ctrl+X / Ctrl+C / Ctrl+V | Undo / Ausschneiden / Kopieren / Einfügen | +| F3 | Suche wiederholen | +| Shift+F2 / Ctrl+F2 | Prozeduren / vorheriger Codeort | + +## Ausführung und Debugger + +| Taste | Aktion | +| --- | --- | +| Shift+F5 / F5 | Start / Continue | +| Ctrl+Break | Unterbrechen | +| Ctrl+C im laufenden Output | Unterbrechen | +| F4 | Output-Vollbild umschalten | +| F7 / F8 / F10 | Run to Cursor / Einzelschritt / Prozedurschritt | +| F9 / Shift+F9 | Breakpoint / Instant Watch | +| Shift+F8 / Shift+F10 | History zurück / vorwärts | + +## Designer + +Pfeile verschieben die Auswahl, Ctrl+Pfeile um fünf Zellen; Shift+Pfeile +ändern die Größe. Tab/Shift+Tab wechseln das Control. F2 aktiviert das +Property-Wertfeld. Page Up/Down wechseln Eigenschaften. F10 wechselt +zwischen Property-Leiste und Menü. F12 führt zum Ereigniscode. + +## Hilfefenster + +Tab/Shift+Tab wählen Links, Enter folgt, Alt+F1 springt zurück und Ctrl+F1 +zum nächsten Thema. Pfeile und Page Up/Down scrollen, Home/End wählen +Anfang/Ende. Esc kehrt zum vorherigen Fenster zurück. + +Details: [Hilfe benutzen](hilfe.md), [Editor](ide-bedienung.md#editor-suche-und-quelltextpflege), +[Designer](ide-bedienung.md#formulare-gestalten), +[Debugger](ide-bedienung.md#debugger-und-direktfenster). diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 0000000..dec1e28 --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,56 @@ +# Tutorial: Vom Formular zum laufenden Programm + +## Projekt und Formular erstellen + +File → New Project erzeugt ein Projekt. File → New Form öffnet ein neues +Formular im Designer. Bereits vorhandene ungespeicherte Arbeit lässt sich +im Wechsel-Dialog speichern, verwerfen oder mit Cancel behalten. +File → Save Project speichert das Projekt und seine Mitglieder; die Pfade +und mögliche Überschreibbestätigungen stehen im Speicherdialog. + +## Oberfläche gestalten + +Tools → Command Button wählt das Werkzeug. Ziehe auf der Form eine Fläche +auf; alternativ platziert ein Doppelklick auf das Toolbox-Werkzeug ein Control. +Wähle das Control und mit Page Up/Down die Eigenschaft Caption. F2 aktiviert +das Wertfeld, tippe `Hallo` und bestätige mit Enter. Pfeile verschieben, +Shift+Pfeile verändern die Größe. Die tatsächlichen Klassennamen und +Eigenschaften stehen in der [Forms-Referenz](forms-referenz.md). + +## Ereigniscode schreiben + +F12 öffnet die Ereignisauswahl des gewählten Controls. Wähle Click und +bestätige. Die IDE öffnet beziehungsweise erzeugt die passende Prozedur. +Schreibe zwischen SUB und END SUB: + +```basic +PRINT "Hallo aus dem Formular" +``` + +Mit Shift+F12 gelangst du wieder zum Formular. Syntaxfehler erscheinen +beim Verlassen der Zeile beziehungsweise unter View → Diagnostics. +Siehe [Editor und Übersetzung](ide-bedienung.md#editor-suche-und-quelltextpflege). + +## Debuggen und ausführen + +Setze im Ereigniscode mit F9 einen Breakpoint auf PRINT. Run → Set Start-up +File wählt das Formular als Startdatei. Shift+F5 startet das Projekt. +Betätige den Button im laufenden Formular: Die IDE hält vor PRINT und +zeigt den Quellort. F8 führt die Anweisung aus. F5 setzt das Programm fort. +F4 schaltet die Programmausgabe ins Vollbild und zurück; Ctrl+Break +unterbricht die Sitzung. Run → Restart beginnt erneut. + +Window → Calls zeigt Aufrufrahmen; Debug → Add Watch beobachtet Ausdrücke. +Window → Immediate öffnet das Direktfenster für Kommandos im pausierten +Kontext. [Debugger und Direktfenster](ide-bedienung.md#debugger-und-direktfenster) +erklärt Fehlerhalte, Watchpoints und die Grenzen von Set Next Statement. + +## Speichern und nächste Schritte + +File → Save Project hält den Stand fest. Make EXE File und Make Library +bieten bereits die Zielauswahl; die tatsächliche Erzeugung eines nativen +Standalone-Programms beziehungsweise einer nativen Library folgt in Phase 6. +Siehe [Exportgrenze](ide-bedienung.md#native-exporte-ui-in-phase-5-erzeugung-in-phase-6). + +Weiter: [Tastatur](tastatur.md), [Hilfe benutzen](hilfe.md), +[Sprachreferenz](sprachreferenz.md), [Bibliothek](bibliothek.md). diff --git a/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md b/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md index 62e98f4..7d17efc 100644 --- a/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md +++ b/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md @@ -34,7 +34,7 @@ Besondere Risiken sind unverändert ausgeführte alte Kompilate nach einer Text | 04 | [Ausführung und Output](../2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md) | 01, 02, 03 | Fortsetzbare VM-Sitzung, Reset, Ausgabe, Shell und Textdruck | | 05 | [Formular-Designer](../2026-09-06-phase-5-05-formular-designer/proposal.md) | 01, 02, 03 | Visuelles Gestalten und konsistenter Ereigniscode | | 06 | [Debugger und Direktfenster](../2026-09-06-phase-5-06-debugger-und-direktfenster/proposal.md) | 03, 04 | Quellgenaues Debuggen, Watches, Immediate, History und Fehlerhalte | -| 07 | [Hilfesystem](../../phase-5-07-hilfesystem/proposal.md) | 02, 03 | Offline-Markdown-Hilfe und Kontextnavigation | +| 07 | [Hilfesystem](../2026-09-07-phase-5-07-hilfesystem/proposal.md) | 02, 03 | Offline-Markdown-Hilfe und Kontextnavigation | | 08 | [Integration und Phasenabnahme](../../phase-5-08-integration-und-phasenabnahme/proposal.md) | 01–07 | Durchgängiger Arbeitsablauf, Befehlsabdeckung und nachgewiesener PLAN-Status | ```text diff --git a/openspec/changes/phase-5-07-hilfesystem/.openspec.yaml b/openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/.openspec.yaml similarity index 100% rename from openspec/changes/phase-5-07-hilfesystem/.openspec.yaml rename to openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/.openspec.yaml diff --git a/openspec/changes/phase-5-07-hilfesystem/design.md b/openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/design.md similarity index 100% rename from openspec/changes/phase-5-07-hilfesystem/design.md rename to openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/design.md diff --git a/openspec/changes/phase-5-07-hilfesystem/proposal.md b/openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/proposal.md similarity index 72% rename from openspec/changes/phase-5-07-hilfesystem/proposal.md rename to openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/proposal.md index b6098e7..90b6320 100644 --- a/openspec/changes/phase-5-07-hilfesystem/proposal.md +++ b/openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/proposal.md @@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt tb-ide-Hilfe, docs/ und Paketierung von tb; nutzt Fensterverwaltung und Editor-Kontext. Kein Webdienst und keine eigene zweite Dokumentationssammlung. -**Abhängigkeiten:** [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). +**Abhängigkeiten:** [phase-5-02-ide-rahmen](../2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). -**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. +**Gesamtplanung:** [Phase-5-Übersicht](../2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. diff --git a/openspec/changes/phase-5-07-hilfesystem/specs/ide-hilfe/spec.md b/openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/specs/ide-hilfe/spec.md similarity index 100% rename from openspec/changes/phase-5-07-hilfesystem/specs/ide-hilfe/spec.md rename to openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/specs/ide-hilfe/spec.md diff --git a/openspec/changes/phase-5-07-hilfesystem/tasks.md b/openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/tasks.md similarity index 68% rename from openspec/changes/phase-5-07-hilfesystem/tasks.md rename to openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/tasks.md index 86ddc2c..a41b864 100644 --- a/openspec/changes/phase-5-07-hilfesystem/tasks.md +++ b/openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/tasks.md @@ -1,17 +1,17 @@ ## 1. Dokumente und Darstellung -- [ ] 1.1 Die relevanten docs-Inhalte mit stabilem Datei-/Themen-/Ankerkatalog in tb einbetten; ein Test startet die Hilfe aus einem temporären Verzeichnis ohne Checkout und findet alle Referenzseiten. -- [ ] 1.2 Den Markdown-Block-/Inline-Pfad mit vorhandenen Abhängigkeiten oder einem einzelnen geeigneten Parser anbinden; Renderproben prüfen Überschriften, Listen, Escapes, Links, Codeblöcke und Tabellen. -- [ ] 1.3 Dynamischen Umbruch und horizontales Scrollen für breite Inhalte implementieren; TestBackend-Prüfungen mit Unicode und Resize erhalten Thema, Leseposition und Linkfokus. +- [x] 1.1 Die relevanten docs-Inhalte mit stabilem Datei-/Themen-/Ankerkatalog in tb einbetten; ein Test startet die Hilfe aus einem temporären Verzeichnis ohne Checkout und findet alle Referenzseiten. +- [x] 1.2 Den Markdown-Block-/Inline-Pfad mit vorhandenen Abhängigkeiten oder einem einzelnen geeigneten Parser anbinden; Renderproben prüfen Überschriften, Listen, Escapes, Links, Codeblöcke und Tabellen. +- [x] 1.3 Dynamischen Umbruch und horizontales Scrollen für breite Inhalte implementieren; TestBackend-Prüfungen mit Unicode und Resize erhalten Thema, Leseposition und Linkfokus. ## 2. Kontext und Navigation -- [ ] 2.1 F1-/Rechtsklick-Kontext aus Editor, Forms-Property und IDE-Befehl auf Themen/Anker abbilden; Tests prüfen Typ-Suffixe, Großschreibung, Mehrdeutigkeit und fehlenden Treffer. -- [ ] 2.2 Tab/Shift+Tab, Enter, Alt+F1, Ctrl+F1 und Esc integrieren; Eingabetests belegen Linkfolge, 20 Rücksprünge, Ende der Themenreihenfolge und vorherigen Fensterfokus. -- [ ] 2.3 Interne Pfade/Anker prüfen und externe Ziele sichtbar ohne automatische Ausführung behandeln; Tests belegen fehlende Ziele, Pfadbegrenzung und unterbleibende Prozess-/Netzwerkaufrufe. +- [x] 2.1 F1-/Rechtsklick-Kontext aus Editor, Forms-Property und IDE-Befehl auf Themen/Anker abbilden; Tests prüfen Typ-Suffixe, Großschreibung, Mehrdeutigkeit und fehlenden Treffer. +- [x] 2.2 Tab/Shift+Tab, Enter, Alt+F1, Ctrl+F1 und Esc integrieren; Eingabetests belegen Linkfolge, 20 Rücksprünge, Ende der Themenreihenfolge und vorherigen Fensterfokus. +- [x] 2.3 Interne Pfade/Anker prüfen und externe Ziele sichtbar ohne automatische Ausführung behandeln; Tests belegen fehlende Ziele, Pfadbegrenzung und unterbleibende Prozess-/Netzwerkaufrufe. ## 3. Hilfe-Einstiege und Abnahme -- [ ] 3.1 Index, Contents, Keyboard, Using Help, Tutorial und About anbinden und fehlende Bedienungsseiten unter docs ergänzen; Menü-/Shortcut-Tests öffnen jeden Einstieg und zeigen die gebaute Version. -- [ ] 3.2 Alle eingebetteten relativen Links und Kontextaliasse mit einer ausführbaren Katalogprüfung absichern; sie muss bei absichtlich fehlendem Ziel scheitern. -- [ ] 3.3 Die Hilfe-Spec-Szenarien in verification.md zuordnen und cargo test -p tb-ide sowie Formatprüfung ausführen; der Tutorial-/Designer-Gesamtweg wird zusätzlich in 08 geprüft. +- [x] 3.1 Index, Contents, Keyboard, Using Help, Tutorial und About anbinden und fehlende Bedienungsseiten unter docs ergänzen; Menü-/Shortcut-Tests öffnen jeden Einstieg und zeigen die gebaute Version. +- [x] 3.2 Alle eingebetteten relativen Links und Kontextaliasse mit einer ausführbaren Katalogprüfung absichern; sie muss bei absichtlich fehlendem Ziel scheitern. +- [x] 3.3 Die Hilfe-Spec-Szenarien in verification.md zuordnen und cargo test -p tb-ide sowie Formatprüfung ausführen; der Tutorial-/Designer-Gesamtweg wird zusätzlich in 08 geprüft. diff --git a/openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/verification.md b/openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/verification.md new file mode 100644 index 0000000..f16728c --- /dev/null +++ b/openspec/changes/archive/2026-09-07-phase-5-07-hilfesystem/verification.md @@ -0,0 +1,84 @@ +# Verification Report: phase-5-07-hilfesystem + +Stand: 2026-09-07. Proposal, Design, neun Aufgaben sowie vier Anforderungen +mit sechs Szenarien wurden gegen den aktuellen Code und ausführbare Tests geprüft. + +## Ergebnis + +| Dimension | Ergebnis | +| --- | --- | +| Completeness | 9/9 Aufgaben; 4/4 Anforderungen umgesetzt | +| Correctness | 6/6 Szenarien zugeordnet und geprüft | +| Coherence | Eingebettete docs als einzige Textquelle; vorhandene App-, Fenster-, Designer- und Renderingpfade | +| Offene Befunde | 0 CRITICAL, 0 WARNING, 0 SUGGESTION | + +## Anforderungen und Szenarien + +Implementierung: `crates/tb-ide/src/help.rs`, Dispatcher in `app.rs`, +Renderaufruf in `render.rs`, Property-Kontext in `designer.rs`. +Neue Nachweise: `crates/tb-ide/tests/help.rs`. + +| Requirement / Scenario | Implementierung und Nachweis | +| --- | --- | +| Mitgelieferte Dokumente / Start außerhalb des Repositorys | `DOCUMENTS` bindet elf aktuelle docs-Dateien und den verlinkten PLAN direkt per `include_str!` ein. `Catalog` erzeugt Index und Contents aus diesen Seiten. `help_works_from_a_directory_without_checkout` startet den Testprozess in einem temporären Verzeichnis ohne docs; `offline_help_child` öffnet dort dieselbe `App`, alle Hilfe-Einstiege sowie Sprach-, Bibliotheks-, Forms-, IDE-Referenz und Tutorial mit TestBackend. Der normale tb-Einstieg benutzt diese App unabhängig vom Arbeitsverzeichnis. | +| Markdown / Schmales Hilfefenster | `Page::parse` verarbeitet CommonMark einschließlich Tabellen mit pulldown-cmark; `layout` berechnet Fließtextzeilen anhand der Zellbreite. Code und Tabellen behalten ihre vollständigen Zeichen und sind horizontal scrollbar. `Position` speichert Seite, Block, Zeichenoffset und Linkfokus. `markdown_blocks_escapes_links_and_unicode_layout` prüft Listen, Formatierung, Escapes, Links, Unicode und verlustfreie Blockprojektion bei vier Breiten. `code_and_tables_remain_reachable_after_resize_and_horizontal_scroll` und `links_history_resize_scroll_and_focus_use_real_events` prüfen schmale TestBackend-Fenster, echte Scrolltasten und Resize bei erhaltenem Leseanker und Linkfokus. | +| Kontextsensitive Themenwahl / Property und BASIC-Funktion | `token_at`, `normalize`, `Catalog::search`, `help_context` und `design_help_context` verbinden BASIC-Tokens samt Typsuffix mit Dokumentabschnitten und Designerklasse/-eigenschaft. `command_target` bildet konkrete Befehls-IDs ab. `context_functions_properties_commands_and_unknown_queries` prüft F1 auf `lEfT$`, Form.Caption im aktiven Property-Wertfeld, den fokussierten Save-Project-Menübefehl, Mehrdeutigkeit und unbekanntes Suchwort. `every_command_and_forms_alias_resolves_and_invalid_alias_fails` prüft alle Menübefehle beider Modi und alle Properties der 19 Forms-Klassen. | +| Links und Verlauf / Rücksprung mit Leseposition | `Help::visit`, `follow`, `back`, `focus_link`, `help_key` halten Navigation und Fenstersitzung zusammen. `links_history_resize_scroll_and_focus_use_real_events` folgt nach Scrollen per Enter einem Link und stellt mit Alt+F1 exakt Seite, Offset und Linkfokus wieder her. Esc restauriert das vorherige Fenster; `help_preserves_fullscreen_and_does_not_modify_designer_or_source` prüft zusätzlich Output-Vollbild, Designerzustand und Suchergebnis-Rücksprünge. | +| Links und Verlauf / Grenze des Verlaufs | `Help::visit` hält maximal 20 Rücksprungzustände. `twenty_back_steps_end_of_contents_and_all_entries` führt 25 Wechsel und anschließend die letzten 20 Rücksprünge aus. Weiteres Zurück verändert keinen Zustand und zeigt den Grenzhinweis. Ctrl+F1 folgt der Contents-Dateireihenfolge und bleibt am Ende mit Hinweis stehen. | +| Hilfe-Einstiege / Tutorial folgen | Alle sieben Help-Einstiege sind angebunden; About zeigt die gebaute Paketversion und Autoren. `twenty_back_steps_end_of_contents_and_all_entries` bedient Menüs und Shortcuts. `docs/tutorial.md` wurde gegen New Project/Form, Toolbox-Platzierung, Caption/F2, F12-Ereignisauswahl, Startdatei, F9/F8/F5 und die vorhandenen Designer-/Debugger-/Ausführungstests abgeglichen. Alle Tutorialverweise werden durch `Catalog::validate` geprüft. Der zusätzliche durchgehende Tutorial-/Designer-Abnahmelauf bleibt gemäß Aufgabe 3.3 in Change 08. | + +## Weitere Vertragsprüfungen + +- `catalog_links_and_missing_targets_are_checked` prüft sämtliche internen + Kataloglinks und Überschriftenanker. Absichtlich fehlende Dateien, Anker, + ungültige Prozentkodierungen und Pfade außerhalb des Katalogs schlagen fehl. + Doppelte Überschriften einschließlich Namenskollisionen erhalten eindeutige Anker. +- Kontextaliasse werden durch dieselbe Zielauflösung geprüft; ein absichtlich + falscher Befehlsanker erzeugt einen Fehler. Klasse und Property stammen aus + der vorhandenen Forms-Metadatenquelle, die Texte weiterhin aus docs. +- `right_click_configuration_and_external_link_follow` prüft den Optionsschalter, + sichtbare externe URLs und Enter auf einem externen Ziel. Es erfolgt kein + Shell-Auftrag; die Hilfe besitzt keinen Datei-, Prozess- oder Netzwerkpfad. + Die bestehende App-Regression belegt weiterhin Rechtsklick-Zustellung an ein + laufendes BASIC-Programm mit Output-Fokus. +- Index sortiert Themen und Abschnitte alphabetisch, Contents bildet die + Dokument-/Überschriftenhierarchie ab. Suche ohne Treffer erhält das Suchwort + und bietet Index/Contents an; Mehrdeutigkeit erzeugt eine bedienbare Linkliste. +- `docs/hilfe.md`, `docs/tastatur.md` und `docs/tutorial.md` sind normale + eingebettete Markdown-Seiten. Es gibt weder eine zweite Referenzsammlung + noch externe Renderer. Die Build-Abhängigkeit pulldown-cmark 0.13.4 wurde + nach Prüfung der CommonMark-/Tabellen-API und MIT-Lizenz ohne Default-Features + aufgenommen. Darstellungsbreiten und Fenster verwenden vorhandene Bibliotheken. + +## Behobene Befunde + +- Frühes Rechtsklick-Routing fing anfangs Programmmauseingaben ab. Kontext-Hilfe + respektiert jetzt den laufenden Output-Fokus; die bisherige App-Regression besteht. +- Hilfe aus Output-Vollbild wurde vom Vollbild verdeckt. Öffnen und Schließen + sichern und restaurieren nun auch diesen Fokuszustand. +- Allgemeine Menügruppen waren für einzelne Befehle zu ungenau. Eine exhaustive + Zuordnung der konkreten Befehls-IDs verweist auf die jeweiligen Bedienabschnitte; + der Katalogtest sichert die Ziele ab. +- Einfache Überschriftenzähler konnten bei einem bereits vorhandenen Suffix + kollidieren. Eine Menge der vergebenen Anker verhindert solche Kollisionen. +- Benachbarte Inline-Spans gleichen Stils werden zusammengefasst, damit + kombinierende Unicode-Zeichen bei der Terminaldarstellung zusammenbleiben. +- Die bisherige Phase-07-Platzhalterdarstellung und die vollständig überholte + Feature-Phasen-Sperre wurden entfernt. Hilfe und Bedienungsübersicht zeigen + den implementierten Stand; die native Exporterzeugung bleibt Phase 6. + +## Abschlussprüfungen + +- `cargo test -p tb-ide`: 79 Tests bestanden, keine Fehler oder Ignore-Fälle; + zusätzlich ein erfolgreicher Offline-Kindprozess mit derselben Hilfe-App. + Enthalten sind elf Hilfetests sowie alle bestehenden App-, Dokument-, Editor-, + Designer-, Debugger- und Ausführungstests. +- `cargo clippy --workspace --all-targets -- -D warnings`: bestanden. +- `cargo fmt --all -- --check`: bestanden. +- `git diff --check`: bestanden. +- `openspec validate --all --strict`: 23/23 gültig. Bestehende INFO-Hinweise + zu langen Anforderungstexten sind keine Validierungsfehler. + +Keine Prüfdimension des Changes wurde ausgelassen. Der zusätzliche Gesamtweg +in Change 08 gehört wie geplant zur späteren Phasenabnahme. Es bestehen keine +offenen Befunde; der Change ist zur Synchronisierung und Archivierung bereit. diff --git a/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md b/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md index fa3a5b2..7a2eee8 100644 --- a/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md +++ b/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md @@ -24,6 +24,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt tb-ide-Integrationstests, vorhandene CLI-/VM-/Forms-Tests und Benchmarks, docs/ sowie PLAN.md. Featureimplementierung bleibt jeweils beim verantwortlichen Change; dieser Change schließt Integration und Nachweise. -**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../archive/2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../archive/2026-09-06-phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../archive/2026-09-06-phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../phase-5-07-hilfesystem/proposal.md). +**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../archive/2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../archive/2026-09-06-phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../archive/2026-09-06-phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../archive/2026-09-07-phase-5-07-hilfesystem/proposal.md). **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. diff --git a/openspec/specs/ide-hilfe/spec.md b/openspec/specs/ide-hilfe/spec.md new file mode 100644 index 0000000..f2653e7 --- /dev/null +++ b/openspec/specs/ide-hilfe/spec.md @@ -0,0 +1,43 @@ +# ide-hilfe Specification + +## Purpose + +Macht die mitgelieferte Markdown-Dokumentation als offline verfügbare, kontextsensitive IDE-Hilfe mit verlässlicher Navigation und variabler Fenstergröße nutzbar. + +## Requirements + +### Requirement: Mitgelieferte Dokumente und Markdown-Darstellung +Die Hilfe SHALL die aktuelle Dokumentation aus docs einschließlich Sprach-, Bibliotheks-, Forms- und IDE-Bedienungsreferenz ohne Netzwerk und unabhängig vom Arbeitsverzeichnis bereitstellen. Überschriften, Absätze, Listen, Codeblöcke, Tabellen und Links SHALL lesbar dargestellt werden; Fließtext SHALL nach Fensterbreite umbrechen, Code und Tabellen SHALL ohne Informationsverlust umgebrochen oder horizontal scrollbar sein. Resize SHALL Thema und logische Leseposition erhalten. + +#### Scenario: Start außerhalb des Repositorys +- **WHEN** das gebaute tb aus einem Verzeichnis ohne docs gestartet wird +- **THEN** sind Index, Referenzthemen und Tutorial weiterhin vollständig erreichbar + +#### Scenario: Schmales Hilfefenster +- **WHEN** ein Thema mit Tabelle, Unicode und Code bei schmaler Fensterbreite geöffnet und anschließend vergrößert wird +- **THEN** bleiben alle Inhalte erreichbar, Links bedienbar und die Leseposition demselben Inhalt zugeordnet + +### Requirement: Kontextsensitive Themenwahl +F1/Topic und entsprechend konfigurierter Rechtsklick SHALL das Thema zum Token unter dem Editorcursor, zur Designerklasse/-eigenschaft oder zum fokussierten IDE-Befehl öffnen. Sprache und Bibliothek SHALL unabhängig von Großschreibung und mit Typ-Suffixen gefunden werden. Bei mehreren passenden Themen SHALL eine Auswahl erscheinen; ohne passenden Kontext SHALL eine verständliche Suche beziehungsweise der Index erscheinen. + +#### Scenario: Property und BASIC-Funktion +- **WHEN** F1 zuerst auf einer Formulareigenschaft und danach auf einer BASIC-Funktion mit Dollar-Suffix ausgelöst wird +- **THEN** öffnet die Hilfe jeweils das zugehörige dokumentierte Thema einschließlich dessen passendem Abschnitt + +### Requirement: Links und Verlauf +Interne Links SHALL als ◄Thema► sichtbar sein. Tab/Shift+Tab SHALL den Linkfokus bewegen, Enter SHALL folgen, Alt+F1 SHALL bis zu 20 besuchte Zustände zurückführen und Ctrl+F1 SHALL das nächste Thema in der dokumentierten Themenreihenfolge öffnen. Esc SHALL die Hilfe schließen und den vorherigen Fokus wiederherstellen. Fehlende Ziele SHALL eine Diagnose liefern; externe Links SHALL als extern erkennbar sein und nicht automatisch einen Prozess oder Netzwerkzugriff auslösen. + +#### Scenario: Rücksprung mit Leseposition +- **WHEN** ein Link nach Scrollen verfolgt und Alt+F1 gedrückt wird +- **THEN** kehrt die Hilfe zu Thema, Leseposition und Linkfokus vor dem Sprung zurück + +#### Scenario: Grenze des Verlaufs +- **WHEN** mehr als 20 Themenwechsel erfolgen +- **THEN** bleiben die letzten 20 Rücksprünge möglich, ältere Einträge werden nachvollziehbar verdrängt und weiterer Rücksprung verändert den Zustand nicht + +### Requirement: Hilfe-Einstiege +Help SHALL Index, Contents, Keyboard, Topic/F1, Using Help/Shift+F1, Tutorial und About anbieten. Index SHALL Themen alphabetisch auffindbar machen und Contents SHALL die Dokumenthierarchie abbilden. Keyboard und Tutorial SHALL die tatsächlich implementierte IDE-Bedienung erklären; About SHALL Version und Copyright anzeigen. + +#### Scenario: Tutorial folgen +- **WHEN** der Benutzer Tutorial öffnet und den dort beschriebenen Weg zum Erstellen, Gestalten, Debuggen und Ausführen verfolgt +- **THEN** stimmen Befehle und Tasten mit der Oberfläche überein und alle internen Verweise sind auflösbar