Files
TerminalBasic/crates/tb-ide/src/help.rs

983 lines
35 KiB
Rust

//! Embedded Markdown documentation. No filesystem, process or network access.
use crate::{
app::{App, Mode, WindowKind},
commands::Command,
};
use anyhow::{anyhow, ensure, Result};
use crossterm::event::{KeyCode as K, KeyEvent, KeyModifiers as M};
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use std::{collections::BTreeSet, sync::OnceLock};
use unicode_width::UnicodeWidthChar;
const DOCUMENTS: &[(&str, &str)] = &[
("docs/hilfe.md", include_str!("../../../docs/hilfe.md")),
(
"docs/tutorial.md",
include_str!("../../../docs/tutorial.md"),
),
(
"docs/tastatur.md",
include_str!("../../../docs/tastatur.md"),
),
(
"docs/ide-bedienung.md",
include_str!("../../../docs/ide-bedienung.md"),
),
(
"docs/sprachreferenz.md",
include_str!("../../../docs/sprachreferenz.md"),
),
(
"docs/bibliothek.md",
include_str!("../../../docs/bibliothek.md"),
),
(
"docs/forms-referenz.md",
include_str!("../../../docs/forms-referenz.md"),
),
(
"docs/dateiformate.md",
include_str!("../../../docs/dateiformate.md"),
),
(
"docs/inventar.md",
include_str!("../../../docs/inventar.md"),
),
(
"docs/ide-referenz.md",
include_str!("../../../docs/ide-referenz.md"),
),
(
"docs/tbvm-design.md",
include_str!("../../../docs/tbvm-design.md"),
),
(
"docs/pcode-bibliotheken.md",
include_str!("../../../docs/pcode-bibliotheken.md"),
),
(
"docs/native-executables.md",
include_str!("../../../docs/native-executables.md"),
),
("PLAN.md", include_str!("../../../PLAN.md")),
];
#[derive(Clone, Debug)]
pub struct Glyph {
pub ch: char,
pub style: Style,
pub link: Option<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(crate::render::dos(1));
}
match event {
Event::Start(Tag::Heading { level, .. }) => {
flush(&mut page.blocks, &mut block);
heading = Some(level as usize);
}
Event::End(TagEnd::Heading(_)) => {
let title = block.text();
let base = slug(&title);
let mut anchor = base.clone();
let mut suffix = 0;
while !anchors.insert(anchor.clone()) {
suffix += 1;
anchor = format!("{base}-{suffix}");
}
if page.headings.is_empty() {
page.title = title.clone();
}
page.headings.push(Heading {
title,
anchor,
block: page.blocks.len(),
level: heading.take().unwrap(),
});
flush(&mut page.blocks, &mut block);
}
Event::Start(Tag::CodeBlock(_)) => {
flush(&mut page.blocks, &mut block);
pre = true;
block.pre = true;
}
Event::End(TagEnd::CodeBlock) => {
flush(&mut page.blocks, &mut block);
pre = false;
}
Event::Start(Tag::Table(_)) => {
flush(&mut page.blocks, &mut block);
pre = true;
block.pre = true;
}
Event::End(TagEnd::Table) => {
flush(&mut page.blocks, &mut block);
pre = false;
}
Event::End(TagEnd::TableHead | TagEnd::TableRow) => {
flush(&mut page.blocks, &mut block);
block.pre = true;
}
Event::Start(Tag::TableCell) => append(&mut block, " | ", style, None),
Event::End(TagEnd::TableCell) => {}
Event::Start(Tag::List(start)) => {
flush(&mut page.blocks, &mut block);
lists.push(start);
}
Event::End(TagEnd::List(_)) => {
flush(&mut page.blocks, &mut block);
lists.pop();
}
Event::Start(Tag::Item) => {
flush(&mut page.blocks, &mut block);
let prefix = match lists.last_mut() {
Some(Some(n)) => {
let p = format!("{n}. ");
*n += 1;
p
}
_ => "".into(),
};
append(
&mut block,
&format!("{}{prefix}", " ".repeat(lists.len().saturating_sub(1))),
style,
None,
);
}
Event::End(TagEnd::Item | TagEnd::Paragraph) => flush(&mut page.blocks, &mut block),
Event::Start(Tag::Strong) => strong += 1,
Event::End(TagEnd::Strong) => strong = strong.saturating_sub(1),
Event::Start(Tag::Emphasis) => emphasis += 1,
Event::End(TagEnd::Emphasis) => emphasis = emphasis.saturating_sub(1),
Event::Start(Tag::Link { dest_url, .. } | Tag::Image { dest_url, .. }) => {
link = Some(page.links.len());
page.links.push(Link {
label: String::new(),
target: dest_url.into_string(),
});
append(&mut block, "", style, link);
}
Event::End(TagEnd::Link | TagEnd::Image) => {
append(&mut block, "", style, link);
if let Some(id) = link.take() {
if external(&page.links[id].target) {
append(
&mut block,
&format!(" [extern: {}]", page.links[id].target),
style,
Some(id),
);
}
}
}
Event::Text(text)
| Event::Code(text)
| Event::InlineHtml(text)
| Event::Html(text) => {
if let Some(id) = link {
page.links[id].label.push_str(&text);
}
if pre {
for (n, line) in text.split('\n').enumerate() {
if n > 0 {
page.blocks.push(std::mem::take(&mut block));
block.pre = true;
}
append(&mut block, &line.replace('\t', " "), style, link);
}
} else {
append(&mut block, &text, style, link);
}
}
Event::SoftBreak => append(&mut block, " ", style, link),
Event::HardBreak => {
flush(&mut page.blocks, &mut block);
block.pre = pre;
}
Event::Rule => {
flush(&mut page.blocks, &mut block);
append(&mut block, "────────", style, None);
flush(&mut page.blocks, &mut block);
}
Event::TaskListMarker(checked) => append(
&mut block,
if checked { "[x] " } else { "[ ] " },
style,
None,
),
_ => {}
}
}
flush(&mut page.blocks, &mut block);
page
}
}
pub fn external(target: &str) -> bool {
target.contains(':') || target.starts_with("//")
}
fn decode(text: &str) -> Result<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(crate::render::dos(1))),
Rect::new(area.x, area.y, area.width, 1),
);
for (y, row) in rows
.iter()
.skip(start)
.take(area.height.saturating_sub(2) as usize)
.enumerate()
{
let block = &page.blocks[row.block];
let mut x = 0;
let shift = if block.pre {
self.help.position.horizontal
} else {
0
};
let mut spans: Vec<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(crate::render::dos(1))
.add_modifier(Modifier::UNDERLINED);
if self.help.position.link == Some(link) {
style = style.bg(crate::render::dos(1)).fg(crate::render::dos(15));
}
}
if let Some(last) = spans.last_mut().filter(|s| s.style == style) {
last.content.to_mut().push(g.ch);
} else {
spans.push(Span::styled(g.ch.to_string(), style));
}
} else if x < shift && end > shift {
spans.push(Span::raw(" ".repeat(end - shift)));
}
x = end;
}
f.render_widget(
Paragraph::new(Line::from(spans)),
Rect::new(area.x, area.y + 1 + y as u16, area.width, 1),
);
}
if area.height > 1 {
let status = if self.help.notice.is_empty() {
"Tab: Link · Enter: folgen · Alt+F1: zurück · Esc: schließen"
} else {
&self.help.notice
};
f.render_widget(
Paragraph::new(status),
Rect::new(area.x, area.bottom() - 1, area.width, 1),
);
}
}
}
pub fn token_at(code: &str, cursor: usize) -> String {
let mut cursor = cursor.min(code.len());
while !code.is_char_boundary(cursor) {
cursor -= 1;
}
let valid = |c: char| c.is_alphanumeric() || matches!(c, '_' | '$' | '%' | '&' | '!' | '#');
let start = code[..cursor]
.char_indices()
.rev()
.take_while(|(_, c)| valid(*c))
.last()
.map_or(cursor, |(i, _)| i);
let end = code[cursor..]
.char_indices()
.find(|(_, c)| !valid(*c))
.map_or(code.len(), |(i, _)| cursor + i);
code[start..end].into()
}
/// Context aliases for concrete command IDs; every target is checked with the catalog.
pub fn command_target(command: Command, designer: bool) -> &'static str {
use Command::*;
match command {
MakeExe | MakeLibrary => {
"docs/ide-bedienung.md#native-exporte-ui-in-phase-5-erzeugung-in-phase-6"
}
NewForm | Form | Events | MenuBar | Grid | Palette | MenuDesign | Toolbox | Tool(_) => {
"docs/ide-bedienung.md#formulare-gestalten"
}
Undo | Cut | Copy | Paste | Clear if designer => {
"docs/ide-bedienung.md#formulare-gestalten"
}
Undo | Cut | Copy | Paste | Clear | Procedures | PreviousCode | NewSub | NewFunction
| Code | IncludedFile | IncludedLines | Find | SelectedText | FindNext | Replace
| LoadText | SaveText => "docs/ide-bedienung.md#editor-suche-und-quelltextpflege",
Diagnostics | SyntaxChecking => "docs/ide-bedienung.md#übersetzung-und-revisionsbindung",
Start | Restart | Continue | Pause | CommandLine | Startup | Output | OutputScreen
| Shell => "docs/ide-bedienung.md#programme-ausführen",
AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches | Trace | History
| Breakpoint | ClearBreakpoints | BreakErrors | SetStatement | RunToCursor | Step
| ProcedureStep | HistoryBack | HistoryForward | NextStatement | Calls | Debug
| Immediate => "docs/ide-bedienung.md#debugger-und-direktfenster",
Display | Paths | SaveOptions => "docs/ide-bedienung.md#display-und-benutzereinstellungen",
RightMouse | Topic | UsingHelp | HelpIndex | HelpContents | HelpWindow | About => {
"docs/hilfe.md"
}
Keyboard => "docs/tastatur.md",
Tutorial | NewProject | NewModule => "docs/tutorial.md#projekt-und-formular-erstellen",
OpenProject | SaveProject | AddFile | RemoveFile | SaveFile | SaveAs | Exit => {
"docs/ide-bedienung.md#speichern-und-wechseln"
}
Print => "docs/ide-bedienung.md#programme-ausführen",
Project | NewWindow | Arrange | NextWindow | PreviousWindow | CloseWindow | MoveWindow
| SizeWindow | Minimize | Maximize | Restore | ControlMenu | FocusWindow(_) => {
"docs/ide-bedienung.md#menüs-fenster-und-dialoge"
}
}
}