Implement and archive Phase 5 help system
This commit is contained in:
24
Cargo.lock
generated
24
Cargo.lock
generated
@@ -328,6 +328,12 @@ dependencies = [
|
|||||||
"hashbrown",
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -393,6 +399,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -611,6 +628,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
"pulldown-cmark",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"tb-frontend",
|
"tb-frontend",
|
||||||
"tb-runtime",
|
"tb-runtime",
|
||||||
@@ -676,6 +694,12 @@ dependencies = [
|
|||||||
"syn 3.0.4",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ ratatui.workspace = true
|
|||||||
crossterm.workspace = true
|
crossterm.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
unicode-width.workspace = true
|
unicode-width.workspace = true
|
||||||
|
pulldown-cmark = { version = "0.13", default-features = false }
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ pub enum Hit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
|
pub help: crate::help::Help,
|
||||||
pub debugger: crate::debugger::Debugger,
|
pub debugger: crate::debugger::Debugger,
|
||||||
pub designer: crate::designer::Designer,
|
pub designer: crate::designer::Designer,
|
||||||
pub session: crate::execution::Session,
|
pub session: crate::execution::Session,
|
||||||
@@ -318,6 +319,7 @@ impl App {
|
|||||||
let (options, errors, config_disk) = Options::load(&config_path);
|
let (options, errors, config_disk) = Options::load(&config_path);
|
||||||
project.include_paths = options.include_paths.clone();
|
project.include_paths = options.include_paths.clone();
|
||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
|
help: Default::default(),
|
||||||
debugger: Default::default(),
|
debugger: Default::default(),
|
||||||
designer: Default::default(),
|
designer: Default::default(),
|
||||||
session: Default::default(),
|
session: Default::default(),
|
||||||
@@ -480,9 +482,6 @@ impl App {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
pub fn availability(&self, command: Command) -> Option<String> {
|
pub fn availability(&self, command: Command) -> Option<String> {
|
||||||
if let Some(phase) = command.feature_phase() {
|
|
||||||
return Some(format!("Fachfunktion folgt in Phase-5-Change {phase:02}"));
|
|
||||||
}
|
|
||||||
use Command::*;
|
use Command::*;
|
||||||
if command == Shell && self.session.host.shell_request.is_some() {
|
if command == Shell && self.session.host.shell_request.is_some() {
|
||||||
return Some("Shell-Übergabe bereits angefordert".into());
|
return Some("Shell-Übergabe bereits angefordert".into());
|
||||||
@@ -575,6 +574,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn action(&mut self, command: Command) -> Result<()> {
|
fn action(&mut self, command: Command) -> Result<()> {
|
||||||
|
if self.help_command(command)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if self.debug_command(command)? {
|
if self.debug_command(command)? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1432,6 +1434,40 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.key(key);
|
self.key(key);
|
||||||
} else if let Event::Mouse(mouse) = event {
|
} 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) {
|
match self.design_mouse(mouse) {
|
||||||
Ok(true) => return,
|
Ok(true) => return,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1622,6 +1658,26 @@ impl App {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
fn key(&mut self, key: KeyEvent) {
|
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) {
|
if self.session.fullscreen && self.dialog.is_none() && key.code == K::F(4) {
|
||||||
self.execute(Command::OutputScreen);
|
self.execute(Command::OutputScreen);
|
||||||
return;
|
return;
|
||||||
@@ -1758,6 +1814,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if self.help_key(key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if key.code == K::F(11)
|
if key.code == K::F(11)
|
||||||
|| matches!(
|
|| matches!(
|
||||||
key.code,
|
key.code,
|
||||||
|
|||||||
@@ -97,15 +97,6 @@ pub enum Command {
|
|||||||
ControlMenu,
|
ControlMenu,
|
||||||
FocusWindow(u64),
|
FocusWindow(u64),
|
||||||
}
|
}
|
||||||
impl Command {
|
|
||||||
pub fn feature_phase(self) -> Option<u8> {
|
|
||||||
use Command::*;
|
|
||||||
match self {
|
|
||||||
HelpIndex | HelpContents | Keyboard | Topic | UsingHelp | Tutorial => Some(7),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Item {
|
pub struct Item {
|
||||||
/// & markiert das sichtbare Mnemonic, … einen Dialog.
|
/// & markiert das sichtbare Mnemonic, … einen Dialog.
|
||||||
|
|||||||
@@ -1104,6 +1104,16 @@ impl App {
|
|||||||
}
|
}
|
||||||
Ok(true)
|
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<()> {
|
pub(crate) fn design_value_focus(&mut self) -> Result<()> {
|
||||||
let node = self.selected_node()?;
|
let node = self.selected_node()?;
|
||||||
let specs = forms::properties(node.class);
|
let specs = forms::properties(node.class);
|
||||||
|
|||||||
972
crates/tb-ide/src/help.rs
Normal file
972
crates/tb-ide/src/help.rs
Normal file
@@ -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<usize>,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Block {
|
||||||
|
pub glyphs: Vec<Glyph>,
|
||||||
|
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<Block>,
|
||||||
|
pub links: Vec<Link>,
|
||||||
|
pub headings: Vec<Heading>,
|
||||||
|
}
|
||||||
|
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<String> {
|
||||||
|
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<usize>) {
|
||||||
|
block
|
||||||
|
.glyphs
|
||||||
|
.extend(text.chars().map(|ch| Glyph { ch, style, link }));
|
||||||
|
}
|
||||||
|
fn flush(blocks: &mut Vec<Block>, 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::<Option<u64>>::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<String> {
|
||||||
|
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<Page>,
|
||||||
|
}
|
||||||
|
impl Catalog {
|
||||||
|
pub fn embedded() -> &'static Self {
|
||||||
|
static CATALOG: OnceLock<Catalog> = 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::<String>()
|
||||||
|
);
|
||||||
|
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<usize>,
|
||||||
|
pub horizontal: usize,
|
||||||
|
pub query: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Help {
|
||||||
|
pub position: Position,
|
||||||
|
pub history: Vec<Position>,
|
||||||
|
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<Row> {
|
||||||
|
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<String>) {
|
||||||
|
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<bool> {
|
||||||
|
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<Span> = 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,3 +14,5 @@ pub mod execution;
|
|||||||
pub mod designer;
|
pub mod designer;
|
||||||
|
|
||||||
pub mod debugger;
|
pub mod debugger;
|
||||||
|
|
||||||
|
pub mod help;
|
||||||
|
|||||||
@@ -413,15 +413,7 @@ impl App {
|
|||||||
kind @ (WindowKind::Calls | WindowKind::Debug | WindowKind::Immediate) => {
|
kind @ (WindowKind::Calls | WindowKind::Debug | WindowKind::Immediate) => {
|
||||||
self.debug_render(f, kind, inner)
|
self.debug_render(f, kind, inner)
|
||||||
}
|
}
|
||||||
kind => put(
|
WindowKind::Help => self.help_render(f, inner),
|
||||||
f,
|
|
||||||
inner,
|
|
||||||
match kind {
|
|
||||||
WindowKind::Help => "Help · Inhalte folgen in Change 07",
|
|
||||||
_ => "Debugger-Inhalte folgen in Change 06",
|
|
||||||
},
|
|
||||||
content_style,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.mode == Mode::Environment {
|
if self.mode == Mode::Environment {
|
||||||
|
|||||||
529
crates/tb-ide/tests/help.rs
Normal file
529
crates/tb-ide/tests/help.rs
Normal file
@@ -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::<String>())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
@@ -32,7 +32,9 @@ Timer, VScrollBar` — alle außer `Menu` über die Toolbox platzierbar
|
|||||||
|
|
||||||
## Eigenschaften/Methoden/Ereignisse je Steuerelement
|
## 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,
|
KeyDown, KeyPress, KeyUp, Load, LostFocus, MouseDown, MouseMove, MouseUp,
|
||||||
Paint, Resize, Unload. Methoden: CLS, DRAG, HIDE, LOAD, MOVE, PRINT,
|
Paint, Resize, Unload. Methoden: CLS, DRAG, HIDE, LOAD, MOVE, PRINT,
|
||||||
PRINTFORM, REFRESH, SHOW, TEXTHEIGHT, TEXTWIDTH, UNLOAD. Eigenschaften:
|
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,
|
MinButton, MousePointer, Parent, ScaleHeight, ScaleWidth, Tag, Top,
|
||||||
Visible, Width, WindowState.
|
Visible, Width, WindowState.
|
||||||
|
|
||||||
**CommandButton** — Ereignisse: Click, DragDrop, DragOver, GotFocus,
|
### CommandButton
|
||||||
|
|
||||||
|
Ereignisse: Click, DragDrop, DragOver, GotFocus,
|
||||||
KeyDown, KeyPress, KeyUp, LostFocus. Methoden: DRAG, MOVE, REFRESH,
|
KeyDown, KeyPress, KeyUp, LostFocus. Methoden: DRAG, MOVE, REFRESH,
|
||||||
SETFOCUS. Eigenschaften: BackColor, Cancel, Caption, CtlName, Default,
|
SETFOCUS. Eigenschaften: BackColor, Cancel, Caption, CtlName, Default,
|
||||||
DragMode, Enabled, Height, Index, Left, MousePointer, Parent, TabIndex,
|
DragMode, Enabled, Height, Index, Left, MousePointer, Parent, TabIndex,
|
||||||
TabStop, Tag, Top, Value, Visible, Width. Darstellung nach Höhe:
|
TabStop, Tag, Top, Value, Visible, Width. Darstellung nach Höhe:
|
||||||
1 = `<Text>`, 2 = einzeiliger Rahmen, ≥3 = Kasten; Mindestgröße 1×3.
|
1 = `<Text>`, 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.
|
KeyPress, KeyUp, LostFocus. Methoden: DRAG, MOVE, REFRESH, SETFOCUS.
|
||||||
Eigenschaften: BackColor, BorderStyle, CtlName, DragMode, Enabled,
|
Eigenschaften: BackColor, BorderStyle, CtlName, DragMode, Enabled,
|
||||||
ForeColor, Height, Index, Left, MousePointer, MultiLine, Parent,
|
ForeColor, Height, Index, Left, MousePointer, MultiLine, Parent,
|
||||||
ScrollBars, SelLength, SelStart, SelText, TabIndex, TabStop, Tag, Text,
|
ScrollBars, SelLength, SelStart, SelText, TabIndex, TabStop, Tag, Text,
|
||||||
Top, Visible, Width.
|
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,
|
Key*, LostFocus, Mouse*. Methoden: ADDITEM, DRAG, MOVE, REFRESH,
|
||||||
REMOVEITEM, SETFOCUS. Eigenschaften: BackColor, CtlName, DragMode, Enabled,
|
REMOVEITEM, SETFOCUS. Eigenschaften: BackColor, CtlName, DragMode, Enabled,
|
||||||
ForeColor, Height, Index, Left, List, ListCount, ListIndex, MousePointer,
|
ForeColor, Height, Index, Left, List, ListCount, ListIndex, MousePointer,
|
||||||
Parent, Sorted, TabIndex, TabStop, Tag, Text, Top, Visible, Width.
|
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).
|
SelStart/SelText/Style/Text (Eigenschaften).
|
||||||
|
|
||||||
`ListIndex = -1` hebt die Auswahl auch bei leerer Liste auf. ADDITEM vor dem
|
`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
|
Die Simple-ComboBox zeigt unter dem Eingabefeld ihre Listeneinträge samt
|
||||||
Auswahlmarkierung; Inhalt wird auf den verfügbaren Listenbereich begrenzt.
|
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.
|
Drag*, GotFocus, Key*, LostFocus. Methoden: DRAG, MOVE, REFRESH, SETFOCUS.
|
||||||
Eigenschaften: BackColor, Caption, CtlName, DragMode, Enabled, ForeColor,
|
Eigenschaften: BackColor, Caption, CtlName, DragMode, Enabled, ForeColor,
|
||||||
Height, Index, Left, MousePointer, Parent, TabIndex, TabStop, Tag, Top,
|
Height, Index, Left, MousePointer, Parent, TabIndex, TabStop, Tag, Top,
|
||||||
Value, Visible, Width.
|
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`
|
Container für Gruppierung (OptionButton-Gruppen). `Index` und `TabIndex`
|
||||||
werden in den Originaldateien auch für Frames gespeichert.
|
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,
|
DRAG, MOVE, REFRESH. Eigenschaften: zusätzlich Index, TabIndex, Alignment,
|
||||||
AutoSize, BorderStyle.
|
AutoSize, BorderStyle.
|
||||||
|
|
||||||
**HScrollBar/VScrollBar** — Ereignisse: Change, Drag*, GotFocus, Key*,
|
### HScrollBar/VScrollBar
|
||||||
|
|
||||||
|
Ereignisse: Change, Drag*, GotFocus, Key*,
|
||||||
LostFocus (**kein** separates Scroll-Ereignis). Eigenschaften: zusätzlich
|
LostFocus (**kein** separates Scroll-Ereignis). Eigenschaften: zusätzlich
|
||||||
Attached (VBDOS-Spezifikum: am Formularrand angedockt; Laufzeit read-only),
|
Attached (VBDOS-Spezifikum: am Formularrand angedockt; Laufzeit read-only),
|
||||||
LargeChange, Min, Max, SmallChange, Value.
|
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`
|
`Style` wählt vertikal/horizontal, `Interval` steuert die Wiederholung; `Value`
|
||||||
läuft zyklisch zwischen `Min` und `Max`. `BorderStyle`, `Width` und `Height`
|
läuft zyklisch zwischen `Min` und `Max`. `BorderStyle`, `Width` und `Height`
|
||||||
sind zur Laufzeit schreibgeschützt.
|
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,
|
für OptionButton-Gruppen. Ereignisse: Click, DblClick, Drag*, GotFocus,
|
||||||
Key*, LostFocus, Mouse*, Paint. Methoden: CLS, DRAG, MOVE, PRINT, REFRESH,
|
Key*, LostFocus, Mouse*, Paint. Methoden: CLS, DRAG, MOVE, PRINT, REFRESH,
|
||||||
SETFOCUS, TEXTHEIGHT, TEXTWIDTH. Eigenschaften: zusätzlich Index, TabIndex,
|
SETFOCUS, TEXTHEIGHT, TEXTWIDTH. Eigenschaften: zusätzlich Index, TabIndex,
|
||||||
TabStop, AutoRedraw, CurrentX, CurrentY, ScaleHeight, ScaleWidth.
|
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.
|
Index, Interval (0 = aus … 65 535 ms), Parent, Tag.
|
||||||
|
|
||||||
Die erste Frist beginnt mit der Aktivierung auf einem sichtbaren Formular
|
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
|
Property-Zuweisungen, SHOW und dem Laden von Arrayelementen sowie beim
|
||||||
Übernehmen eines vorbereiteten Modells; es gibt keinen globalen Zeitcache.
|
Ü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.
|
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;
|
(Path/Drive/FileName/Pattern/Archive/Hidden/Normal/ReadOnly/System;
|
||||||
Ereignisse u. a. Change, PathChange, PatternChange). Terminal Basic bildet
|
Ereignisse u. a. Change, PathChange, PatternChange). Terminal Basic bildet
|
||||||
sie plattformneutral nach (Laufwerksliste → Wurzeln/Mounts).
|
sie plattformneutral nach (Laufwerksliste → Wurzeln/Mounts).
|
||||||
|
|||||||
41
docs/hilfe.md
Normal file
41
docs/hilfe.md
Normal file
@@ -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).
|
||||||
@@ -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
|
explizit in [PLAN.md, Phase 6](../PLAN.md). Ein umbenanntes TBC erfüllt
|
||||||
keines der beiden Exportziele.
|
keines der beiden Exportziele.
|
||||||
|
|
||||||
## Befehlsabdeckung nach Change 06
|
## Befehlsabdeckung nach Change 07
|
||||||
|
|
||||||
Alle Referenzbefehle sind in ihrer Menüfolge vorhanden. Fachbefehle der
|
Alle Referenzbefehle sind in ihrer Menüfolge vorhanden und angebunden.
|
||||||
folgenden Changes sind sichtbar deaktiviert und zeigen einen
|
Nicht verfügbare Aktionen zeigen einen zustandsabhängigen Grund; ihre
|
||||||
Verfügbarkeitsgrund; ihre Kürzel gelangen nicht versehentlich an BASIC.
|
Kürzel gelangen nicht versehentlich an BASIC.
|
||||||
Ctrl+C im Editor ist Kopieren, bei laufendem Programm mit
|
Ctrl+C im Editor ist Kopieren, bei laufendem Programm mit
|
||||||
Output-Fokus unterbricht Ctrl+C sofort die Sitzung, ohne eine BASIC-Taste einzureihen.
|
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 | — |
|
| Search | Find, Selected Text, Repeat Last Find, Change | — |
|
||||||
| Run | Start/Restart/Continue/Pause, COMMAND$, wirksame Startdatei und beide Make-Dialoge | Native Erzeugung: Phase 6 |
|
| 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 | — |
|
| 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 |
|
| 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 | Hilfeinhalt: 07 |
|
| 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 | — |
|
| 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.
|
Phase-5-Abnahme erfolgt in Change 08.
|
||||||
|
|
||||||
## Programme ausführen
|
## 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
|
nach Änderungen zuerst neu starten. F5 kann weiterhin nach ausdrücklicher
|
||||||
Auswahl das gekennzeichnete alte Kompilat fortsetzen; seine Watches verwenden
|
Auswahl das gekennzeichnete alte Kompilat fortsetzen; seine Watches verwenden
|
||||||
die ursprünglichen Symbole und Slots.
|
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.
|
||||||
|
|||||||
46
docs/tastatur.md
Normal file
46
docs/tastatur.md
Normal file
@@ -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).
|
||||||
56
docs/tutorial.md
Normal file
56
docs/tutorial.md
Normal file
@@ -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).
|
||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 08 | [Integration und Phasenabnahme](../../phase-5-08-integration-und-phasenabnahme/proposal.md) | 01–07 | Durchgängiger Arbeitsablauf, Befehlsabdeckung und nachgewiesener PLAN-Status |
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
## 1. Dokumente und Darstellung
|
## 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.
|
- [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.
|
||||||
- [ ] 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.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.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. 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.
|
- [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.
|
||||||
- [ ] 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.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.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. 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.
|
- [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.
|
||||||
- [ ] 3.2 Alle eingebetteten relativen Links und Kontextaliasse mit einer ausführbaren Katalogprüfung absichern; sie muss bei absichtlich fehlendem Ziel scheitern.
|
- [x] 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.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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
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.
|
**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.
|
||||||
|
|||||||
43
openspec/specs/ide-hilfe/spec.md
Normal file
43
openspec/specs/ide-hilfe/spec.md
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user