diff --git a/crates/tikzarium-app/QA_B07_NATIVE_DRAWING.md b/crates/tikzarium-app/QA_B07_NATIVE_DRAWING.md new file mode 100644 index 00000000..6aa09544 --- /dev/null +++ b/crates/tikzarium-app/QA_B07_NATIVE_DRAWING.md @@ -0,0 +1,17 @@ +# B07 native TkZ GUI screenshot smoke · all NOT_RUN +For each OS record exact PR HEAD, source hash, OS version, CPU/GPU, DPI, MiKTeX/TeX Live engine path/version, Poppler version, PDF SHA and screenshots with SHA-256. Windows 10 x64 **physical** and Mac **physical** M1/M4 are required. Windows Server 2022 or hosted macOS CI does NOT substitute. + +| Scenario | Check | Win10 | Mac M1 | Mac M4 | +| --- | --- | --- | --- | --- | +| Split 1366x768 | Source, exact PDF+guide and properties/help visible together | NOT_RUN | NOT_RUN | NOT_RUN | +| Create six primitives | Palette click/drag -> source selected -> TeX command | NOT_RUN | NOT_RUN | NOT_RUN | +| Parameters | x/y, circle radius, cubic bend, node text -> narrow undoable patch | NOT_RUN | NOT_RUN | NOT_RUN | +| Source-first | Opaque macros/comments/multiple pictures/tampered marker never overwritten | NOT_RUN | NOT_RUN | NOT_RUN | +| F1/hover help | Russian syntax, type/units, default/UNKNOWN, example, source section, prerequisites | NOT_RUN | NOT_RUN | NOT_RUN | +| Load diff | Installed-but-not-loaded offers explicit usepackage diff + undo, never automatic install | NOT_RUN | NOT_RUN | NOT_RUN | +| Build | Save -> external XeLaTeX -> exact PDF; verify different guide/PDF, log & checksum | NOT_RUN | NOT_RUN | NOT_RUN | +| Reopen/undo | Reopen generated .tex, select command, change, undo; preserve other bytes | NOT_RUN | NOT_RUN | NOT_RUN | +| Native UX | Open/Save As, Cyrillic IME, Light/Dark/Midnight/Paper, compact/comfortable | NOT_RUN | NOT_RUN | NOT_RUN | +| Scaling | Windows 100/125/150%, Retina, no collapsed controls | NOT_RUN | NOT_RUN | NOT_RUN | + +Screenshots B07___.png. Attach full command log, original/after diff, package statuses. No screenshot/host PASS is claimed by writing this protocol. The B07 guide is a separate rough pad, never an overlay precisely tied to arbitrary TikZ transforms. diff --git a/crates/tikzarium-app/QA_B08_LIBRARY_HELP.md b/crates/tikzarium-app/QA_B08_LIBRARY_HELP.md new file mode 100644 index 00000000..eb0d640d --- /dev/null +++ b/crates/tikzarium-app/QA_B08_LIBRARY_HELP.md @@ -0,0 +1,30 @@ +# GUI QA B08 — native TikZ via shared C catalogue + +Status as of 2026-10-31: test design / NOT_RUN on physical Win10, M1, M4. This is not a screenshot result. + +## Expected walkthrough + +Launch with the user's external XeLaTeX configuration. Open a scratch project that explicitly loads tikz. In Split mode ensure three adjacent areas are visible: source editor, exact XeLaTeX PDF (read-only) with a separately marked *approximate* guide pad, and compact inspector/help. Draw a line, rectangle, circle, cubic Bézier, and text/math node by click/drag. After each action inspect the inserted TikZ command in source and selection highlight. Modify known numeric parameters, click Apply, Build with external XeLaTeX, check PDF and Undo, Save/Open, compare all unrelated source bytes. + +Arrow is a distinct negative/positive test. With tikz installed and explicitly loaded but no arrows.meta, the Arrow tool must explain its unavailable state and provide the exact proposed \usetikzlibrary{arrows.meta} preamble insertion. Approve it, insert arrow, change its tip Stealth/Latex, Undo source edit, Undo library addition. Other tools must remain available without arrows.meta. Do not confuse availability of .sty files with project imports. + +In F1 help and hover for each tool inspect the SAME C ToolSpec used by the toolbar; inspect all exposed C ParameterSpec values: TikZ syntax, kind/type, unit, permitted enum, editor preset vs verified PGF default (or UNKNOWN), Russian explanation, effect, example, warnings and manual section. Switch node between escaped text (e.g. Привет_мир) and user-entered math (e.g. \alpha+\beta); verify source string and real PDF. + +Manual direct Source changes and unknown macros must be preserved byte-for-byte, invalidate visual undo safely and never become a new visually editable object. Reload recognized B08 commands, edit known ones only. For multiple tikzpictures or opaque transforms the guide must not pretend to map to arbitrary PDF geometry. + +Check Light/Dark/Midnight/Paper and compact/comfortable persistence separately from unchanged TeX/PDF. At 1366x768 and Retina capture full-window screenshots. Check toolbar, hover, F1, file dialog, Unicode Windows/macOS paths, keyboard shortcuts and IME Cyrillic. Mac M1 and M4 require separate physical QA; hosted macOS ARM jobs are build evidence only. + +## Evidence slots + +- Exact B08 SHA and cargo fmt/check/test/clippy logs: PENDING. +- C07 catalog dependency exact SHA and generator fixture parity: PENDING. +- XeLaTeX six-primitives manual Linux fixture and PDF visual: PASS, SHA in insights. +- Self-contained repository fixture and CI: NOT_RUN until workflow. +- Windows hosted and macOS hosted app tests: PENDING. +- Physical Windows 10 + separately installed MiKTeX / Mac M1 / Mac M4: NOT_RUN. +- Screenshots, actual GUI drag + save/reopen/undo and accessibility at both densities: NOT_RUN. + +Do not label the B08 PR merge-ready until exact-head tests and integration review pass. + + +A local exact-text copy of the B08 checked-in six-primitives ASCII fixture was separately compiled by Linux XeLaTeX with no-shell-escape: PASS exit 0, one-page PDF and visual raster inspection. Source SHA-256 278c9219a27d4e49bf9178de3d238fe3fdcaf5f6b6c8e78dae53ade7f1fe5eba; PDF SHA-256 ece7edf94a03bfd445427813a59244276d9ab77b016b0c610c004e1154db9883. This is source-level verification, not GUI acceptance or compiled C generator parity. B08 Rust CI on ceab19a9 is blocked by C07 malformed source; no app CI PASS or physical GUI PASS claimed. diff --git a/crates/tikzarium-app/fixtures/b07_native_six.tex b/crates/tikzarium-app/fixtures/b07_native_six.tex new file mode 100644 index 00000000..db8fe1f7 --- /dev/null +++ b/crates/tikzarium-app/fixtures/b07_native_six.tex @@ -0,0 +1,15 @@ +\documentclass{standalone} +\usepackage{fontspec} +\usepackage{tikz} +\begin{document} +\begin{tikzpicture} + % Existing opaque user content must stay byte-for-byte unchanged. + \path (0,0); + \draw (0.00,0.00) -- (2.00,1.00); % tikzarium:b07:1:line + \draw[->] (0.00,1.00) -- (2.00,2.00); % tikzarium:b07:2:arrow + \draw (2.00,0.00) rectangle (4.00,1.00); % tikzarium:b07:3:rectangle + \draw (4.00,2.00) circle[radius=0.75cm]; % tikzarium:b07:4:circle + \node at (1.00,2.50) {Text}; % tikzarium:b07:5:node + \draw (0.00,3.00) .. controls (0.67,3.80) and (1.33,3.80) .. (2.00,3.00); % tikzarium:b07:6:cubic +\end{tikzpicture} +\end{document} diff --git a/crates/tikzarium-app/fixtures/b08_catalog_six.tex b/crates/tikzarium-app/fixtures/b08_catalog_six.tex new file mode 100644 index 00000000..b3d91b3b --- /dev/null +++ b/crates/tikzarium-app/fixtures/b08_catalog_six.tex @@ -0,0 +1,17 @@ +\documentclass{article} +\usepackage{tikz} +\usetikzlibrary{arrows.meta} +\pagestyle{empty} +\newcommand{\usersymbol}{Original user macro} +\begin{document} +\begin{tikzpicture} + \draw (0.00cm,0.00cm) -- (2.00cm,1.00cm); % tikzarium:b08:1:line + \draw[-{Stealth}] (0.00cm,1.00cm) -- (2.00cm,1.00cm); % tikzarium:b08:2:arrow + \draw (0.00cm,0.00cm) rectangle (2.00cm,1.00cm); % tikzarium:b08:3:rectangle + \draw (1.00cm,1.00cm) circle[radius=0.80cm]; % tikzarium:b08:4:circle + \node at (3.00cm,1.00cm) {Text}; % tikzarium:b08:5:node + \draw (0.00cm,0.00cm) .. controls (1.00cm,1.60cm) and (2.00cm,1.60cm) .. (3.00cm,1.00cm); % tikzarium:b08:6:cubic + \node at (3.00cm,0.00cm) {$\alpha+\beta$}; + \node at (3.00cm,-1.00cm) {\usersymbol}; +\end{tikzpicture} +\end{document} diff --git a/crates/tikzarium-app/src/main.rs b/crates/tikzarium-app/src/main.rs index 5a076c1d..125c14f7 100644 --- a/crates/tikzarium-app/src/main.rs +++ b/crates/tikzarium-app/src/main.rs @@ -15,6 +15,8 @@ mod font_proof; mod pdf_navigation; mod preview_image; mod source_fonts; +mod tikz_authoring; +mod tikz_canvas; mod ui_state; mod ui_theme; use ui_state::{ @@ -64,6 +66,8 @@ struct TikzariumApp { show_log: bool, pending_open: Option, prefs: UiPreferences, + authoring: tikz_authoring::Session, + highlight_generated_line: bool, font_proof_rx: Option>>, font_proof_status: String, font_proof_report: String, @@ -112,6 +116,12 @@ impl TikzariumApp { show_log: true, pending_open: None, prefs, + authoring: { + let mut state = tikz_authoring::Session::default(); + state.reset(QUICKSTART); + state + }, + highlight_generated_line: false, font_proof_rx: None, font_proof_status: "Not tested".into(), font_proof_report: String::new(), @@ -140,6 +150,8 @@ impl TikzariumApp { Ok(document) => { self.file_field = path.display().to_string(); self.document = document; + self.authoring.reset(&self.document.source); + self.highlight_generated_line = false; self.built_source = None; self.built_path = None; self.building_source = None; @@ -511,13 +523,30 @@ impl TikzariumApp { .desired_width(f32::INFINITY) .desired_rows(36) .show(ui); + if output.response.changed() { + self.authoring.note_manual_edit(&self.document.source); + } if let Some(line) = self.jump_to_line.take() { if let Some(position) = line_start_char_index(&self.document.source, line) { use egui::text::{CCursor, CCursorRange}; - output - .state - .cursor - .set_char_range(Some(CCursorRange::one(CCursor::new(position)))); + output.state.cursor.set_char_range(Some( + if self.highlight_generated_line { + let count = self + .document + .source + .lines() + .nth(line.saturating_sub(1)) + .map(|text| text.trim_end_matches('\r').chars().count()) + .unwrap_or(0); + CCursorRange::two( + CCursor::new(position), + CCursor::new(position + count), + ) + } else { + CCursorRange::one(CCursor::new(position)) + }, + )); + self.highlight_generated_line = false; output.state.store(ui.ctx(), output.response.id); output.response.request_focus(); let row_height = ui.text_style_height(&egui::TextStyle::Monospace); @@ -537,6 +566,48 @@ impl TikzariumApp { }); } + fn primitive_capabilities(&self) -> tikz_canvas::Capabilities { + let core = self + .package_statuses + .iter() + .find(|status| status.manifest.id == "org.tikzarium.core"); + tikz_canvas::Capabilities { + installed: core.is_some_and(|status| status.installed), + loaded: tikz_authoring::tikz_loaded(&self.document.source), + engine_compatible: self.tex.xelatex.is_some() + && core.is_some_and(|status| { + status.compilation != tikzarium_extensions::CompilationState::IncompatibleEngine + }), + implemented: true, // B handles scoped interaction; C owns syntax and generation. + arrow_library_loaded: tikz_authoring::library_loaded( + &self.document.source, + "arrows.meta", + ), + } + } + + fn show_authoring_inspector(&mut self, ui: &mut egui::Ui) { + let caps = self.primitive_capabilities(); + if let Some(line) = + tikz_canvas::show_inspector(ui, &mut self.authoring, &mut self.document.source, caps) + { + self.jump_to_line = Some(line); + self.highlight_generated_line = true; + } + if self.show_packages { + ui.separator(); + ui.collapsing("Library inventory · from C registry", |ui| { + for item in &self.package_statuses { + ui.strong(&item.manifest.display_name); + ui.small(format!( + "installed={:?}, compilation={:?}, inspectable={}, editable={}", + item.installation, item.compilation, item.inspectable, item.visual_editable, + )); + } + }); + } + } + fn show_pdf_canvas(&mut self, ui: &mut egui::Ui) { let canvas_width = ui.available_width(); ui.horizontal_wrapped(|ui| { @@ -597,7 +668,14 @@ impl TikzariumApp { } ui.label(&self.preview_status); ui.small(&self.page_navigation_status); - ui.small("Pages are rasterized from the original PDF by Poppler, without changing TeX. Canvas edits are not implemented."); + ui.small("Only the guide pad below makes B08 C-catalogue source patches. This exact PDF is read-only and is NOT coordinate-aligned with the guide."); + let caps = self.primitive_capabilities(); + if let Some(line) = + tikz_canvas::show_guide(ui, &mut self.authoring, &mut self.document.source, caps) + { + self.jump_to_line = Some(line); + self.highlight_generated_line = true; + } ui.separator(); egui::ScrollArea::both() .auto_shrink([false, false]) @@ -793,6 +871,9 @@ impl eframe::App for TikzariumApp { self.poll_pages(ctx); self.poll_font_proof(ctx); self.show_font_lab(ctx); + if ctx.input(|input| input.key_pressed(egui::Key::F1)) { + self.authoring.help = !self.authoring.help; + } let shortcut = ctx.input(|input| { if input.key_pressed(egui::Key::S) { editor_command(egui::Key::S, input.modifiers) @@ -897,7 +978,7 @@ impl eframe::App for TikzariumApp { if ui.button("Figure fonts").clicked() { self.prefs.show_font_lab = !self.prefs.show_font_lab; } - ui.checkbox(&mut self.show_packages, "Packages"); + ui.checkbox(&mut self.show_packages, "Library inventory"); ui.checkbox(&mut self.show_log, "Diagnostics / log"); if self.document.dirty() { ui.colored_label(egui::Color32::YELLOW, "Unsaved edits"); @@ -908,29 +989,15 @@ impl eframe::App for TikzariumApp { ui.label(&self.status); }); }); - if self.show_packages { - egui::SidePanel::right("packages") + if self.workspace.show_canvas() { + egui::SidePanel::right("tikz-source-inspector") .resizable(true) - .default_width(245.0) + .default_width(250.0) + .min_width(210.0) .show(ctx, |ui| { - ui.heading("Libraries"); - ui.small("Package detection ≠ verified compilation ≠ visual editing"); - ui.separator(); - egui::ScrollArea::vertical().show(ui, |ui| { - for item in &self.package_statuses { - let state = if item.installed { - "Installed" - } else { - "Missing" - }; - ui.strong(format!("{} — {state}", item.manifest.display_name)); - if !item.missing_files.is_empty() { - ui.small(format!("Needed: {}", item.missing_files.join(", "))); - } - ui.small("Visual editing: not yet implemented"); - ui.separator(); - } - }); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| self.show_authoring_inspector(ui)); }); } if self.show_log { @@ -982,6 +1049,7 @@ impl eframe::App for TikzariumApp { && ui.button(&label).clicked() { self.jump_to_line = Some(line); + self.highlight_generated_line = false; if !self.workspace.show_source() { self.workspace = WorkspaceMode::Split; } diff --git a/crates/tikzarium-app/src/tikz_authoring.rs b/crates/tikzarium-app/src/tikz_authoring.rs new file mode 100644 index 00000000..35ed3e86 --- /dev/null +++ b/crates/tikzarium-app/src/tikz_authoring.rs @@ -0,0 +1,928 @@ +//! B07: narrow TikZ-native source edits for a first egui authoring vertical. +//! This is NOT a TeX parser, a PDF coordinate mapper, or a geometry engine. +//! The source owns every generated object; unknown code is never rewritten. + +use std::{ + collections::{BTreeMap, hash_map::DefaultHasher}, + hash::{Hash, Hasher}, + ops::Range, + sync::OnceLock, +}; +use tikzarium_extensions::primitives::{Catalog, SourcePatch}; + +pub const MARKER: &str = " % tikzarium:b08:"; +pub const TOOLS: [Tool; 6] = [ + Tool::Line, + Tool::Arrow, + Tool::Rectangle, + Tool::Circle, + Tool::Node, + Tool::Cubic, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tool { + Line, + Arrow, + Rectangle, + Circle, + Node, + Cubic, +} + +impl Tool { + pub const fn id(self) -> &'static str { + match self { + Self::Line => "line", + Self::Arrow => "arrow", + Self::Rectangle => "rectangle", + Self::Circle => "circle", + Self::Node => "node", + Self::Cubic => "cubic", + } + } + pub const fn catalog_id(self) -> &'static str { + match self { + Self::Line => "tikz.line", + Self::Arrow => "tikz.arrow", + Self::Rectangle => "tikz.rectangle", + Self::Circle => "tikz.circle", + Self::Node => "tikz.node", + Self::Cubic => "tikz.bezier-cubic", + } + } + fn from_id(id: &str) -> Option { + TOOLS.into_iter().find(|tool| tool.id() == id) + } +} + +/// The C-owned TOML catalogue is the sole source for tool names, requirements, +/// executable generation, parameter semantics, examples and F1/hover text. +pub fn catalog() -> &'static Catalog { + static CATALOG: OnceLock = OnceLock::new(); + CATALOG.get_or_init(|| Catalog::bundled().expect("bundled primitive catalogue must validate")) +} +pub fn spec(tool: Tool) -> &'static tikzarium_extensions::primitives::ToolSpec { + catalog() + .tool(tool.catalog_id()) + .expect("six stable C tool IDs") +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Point { + pub x: f32, + pub y: f32, +} +impl Point { + pub const fn new(x: f32, y: f32) -> Self { + Self { x, y } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Draft { + pub start: Point, + pub end: Point, + pub bend: f32, + pub text: String, + pub tip: String, + pub mode: String, +} +impl Default for Draft { + fn default() -> Self { + Self { + start: Point::new(1.0, 1.0), + end: Point::new(3.0, 2.0), + bend: 0.8, + text: "Text".into(), + tip: "Stealth".into(), + mode: "text".into(), + } + } +} + +fn safe_number(value: f32) -> bool { + value.is_finite() && value.abs() <= 1000.0 +} +fn safe_text(text: &str) -> bool { + !text.is_empty() && text.chars().count() <= 80 && !text.chars().any(char::is_control) +} +fn literal_cm(value: f32) -> String { + format!("{value:.2}cm") +} +/// B retains only pointer geometry. C owns the syntax, units and validation. +pub fn parameter_values(tool: Tool, draft: &Draft) -> Result, String> { + if ![ + draft.start.x, + draft.start.y, + draft.end.x, + draft.end.y, + draft.bend, + ] + .into_iter() + .all(safe_number) + { + return Err("Coordinates must be finite and within ±1000".into()); + } + if tool == Tool::Node && !safe_text(&draft.text) { + return Err( + "Node content must be 1–80 Unicode characters without control characters".into(), + ); + } + let mut p = BTreeMap::new(); + let a = draft.start; + let b = draft.end; + match tool { + Tool::Line | Tool::Arrow | Tool::Rectangle | Tool::Cubic => { + for (key, value) in [ + ("start_x", a.x), + ("start_y", a.y), + ("end_x", b.x), + ("end_y", b.y), + ] { + p.insert(key.to_owned(), literal_cm(value)); + } + } + Tool::Circle => { + p.insert("center_x".into(), literal_cm(a.x)); + p.insert("center_y".into(), literal_cm(a.y)); + p.insert( + "radius".into(), + literal_cm((b.x - a.x).hypot(b.y - a.y).max(0.1)), + ); + } + Tool::Node => { + p.insert("x".into(), literal_cm(a.x)); + p.insert("y".into(), literal_cm(a.y)); + p.insert("content".into(), draft.text.clone()); + p.insert("mode".into(), draft.mode.clone()); + } + } + if tool == Tool::Arrow { + p.insert("tip".into(), draft.tip.clone()); + } + if tool == Tool::Cubic { + let dx = (b.x - a.x) / 3.0; + for (key, value) in [ + ("control1_x", a.x + dx), + ("control1_y", a.y + draft.bend), + ("control2_x", b.x - dx), + ("control2_y", b.y + draft.bend), + ] { + p.insert(key.to_owned(), literal_cm(value)); + } + } + Ok(p) +} +pub fn generate(tool: Tool, draft: &Draft, id: u64) -> Result { + let params = parameter_values(tool, draft)?; + let command = catalog() + .generate(tool.catalog_id(), ¶ms) + .map_err(|error| error.to_string())?; + Ok(format!(" {}{MARKER}{id}:{}", command.source, tool.id())) +} + +fn read_point(raw: &str) -> Option { + let (x, y) = raw.split_once(',')?; + let read = |v: &str| -> Option { v.strip_suffix("cm")?.parse().ok() }; + let point = Point::new(read(x)?, read(y)?); + (safe_number(point.x) && safe_number(point.y)).then_some(point) +} + +fn points(source: &str) -> Option> { + let mut remaining = source; + let mut out = Vec::new(); + while let Some(open) = remaining.find('(') { + remaining = &remaining[open + 1..]; + let close = remaining.find(')')?; + out.push(read_point(&remaining[..close])?); + remaining = &remaining[close + 1..]; + } + Some(out) +} + +#[derive(Clone, Debug)] +pub struct Owned { + pub id: u64, + pub tool: Tool, + pub draft: Draft, + pub line: usize, + pub text: String, + pub range: Range, +} + +/// An owned line is editable only if it still exactly matches B07's generator. +/// A hand-modified command, even with the marker left in place, is opaque. +/// Reverse only C's known text escaping, never TeX macro expansion. +fn decode_generated_text(raw: &str) -> String { + let mut value = raw.to_owned(); + for (encoded, literal) in [ + (r"\textbackslash{}", r"\"), + (r"\textasciicircum{}", "^"), + (r"\textasciitilde{}", "~"), + (r"\{", "{"), + (r"\}", "}"), + (r"\$", "$"), + (r"\&", "&"), + (r"\%", "%"), + (r"\#", "#"), + (r"\_", "_"), + ] { + value = value.replace(encoded, literal); + } + value +} + +fn parse_line(line: &str, start: usize, number: usize) -> Option { + let (command, tag) = line.split_once(MARKER)?; + let (id, kind) = tag.split_once(':')?; + let id: u64 = id.parse().ok()?; + let tool = Tool::from_id(kind)?; + // Node content can contain parentheses; only the native coordinate prefix + // is recognized. Generated source must still round-trip byte-for-byte. + let coordinate_part = if tool == Tool::Node { + command.split_once(" {")?.0 + } else { + command + }; + let coords = points(coordinate_part)?; + let mut draft = Draft::default(); + match tool { + Tool::Line | Tool::Arrow | Tool::Rectangle if coords.len() == 2 => { + draft.start = coords[0]; + draft.end = coords[1]; + if tool == Tool::Arrow { + draft.tip = command.split_once("[-{")?.1.split_once("}]")?.0.to_owned(); + } + } + Tool::Circle if coords.len() == 1 => { + let radius: f32 = command + .split("radius=") + .nth(1)? + .strip_suffix("cm];")? + .parse() + .ok()?; + draft.start = coords[0]; + draft.end = Point::new(coords[0].x + radius, coords[0].y); + } + Tool::Node if coords.len() == 1 => { + let text = command.split_once(" {")?.1.strip_suffix("};")?; + draft.start = coords[0]; + if let Some(math) = text.strip_prefix('$').and_then(|v| v.strip_suffix('$')) { + draft.mode = "math".into(); + draft.text = math.into(); + } else { + draft.mode = "text".into(); + draft.text = decode_generated_text(text); + } + } + Tool::Cubic if coords.len() == 4 => { + draft.start = coords[0]; + draft.end = coords[3]; + draft.bend = ((coords[1].y - coords[0].y) * 100.0).round() / 100.0; + } + _ => return None, + } + // Exact round-trip is the guard against styling or user macro changes. + if generate(tool, &draft, id).ok()?.as_str() != line { + return None; + } + Some(Owned { + id, + tool, + draft, + line: number, + text: line.into(), + range: start..start + line.len(), + }) +} + +pub fn owned(source: &str) -> Vec { + let mut offset = 0; + let mut found = Vec::new(); + for (index, line) in source.split_inclusive('\n').enumerate() { + let body = line.trim_end_matches(&['\r', '\n'][..]); + if let Some(item) = parse_line(body, offset, index + 1) { + found.push(item); + } + offset += line.len(); + } + found +} + +fn one_owned(source: &str, id: u64) -> Result { + let matches: Vec<_> = owned(source) + .into_iter() + .filter(|item| item.id == id) + .collect(); + if matches.len() != 1 { + return Err("Owned command absent, duplicated or manually changed: source-first".into()); + } + let marker = format!("{MARKER}{id}:"); + if source.matches(&marker).count() != 1 { + return Err( + "Conflicting or modified ownership marker: manual source stays untouched".into(), + ); + } + Ok(matches[0].clone()) +} + +fn unescaped_comment(line: &str) -> &str { + let mut backslashes = 0usize; + for (i, c) in line.char_indices() { + if c == '%' && backslashes.is_multiple_of(2) { + return &line[..i]; + } + backslashes = if c == '\\' { backslashes + 1 } else { 0 }; + } + line +} + +/// Macro-generated imports are UNKNOWN. Only literal project declarations +/// grant a visual tool permission to insert its package-dependent source. +fn has_declaration(line: &str, command: &str, name: &str) -> bool { + let Some(mut rest) = line.strip_prefix(command) else { + return false; + }; + if let Some(options) = rest.strip_prefix('[') { + let Some((_, remaining)) = options.split_once(']') else { + return false; + }; + rest = remaining; + } + let Some((names, _)) = rest.strip_prefix('{').and_then(|body| body.split_once('}')) else { + return false; + }; + names.split(',').any(|entry| entry == name) +} +fn literal_loaded(source: &str, commands: &[&str], name: &str) -> bool { + source.lines().map(unescaped_comment).any(|line| { + let compact: String = line.chars().filter(|c| !c.is_whitespace()).collect(); + commands + .iter() + .any(|command| has_declaration(&compact, command, name)) + }) +} +pub fn tikz_loaded(source: &str) -> bool { + literal_loaded(source, &[r"\usepackage", r"\RequirePackage"], "tikz") +} +pub fn library_loaded(source: &str, library: &str) -> bool { + literal_loaded(source, &[r"\usetikzlibrary"], library) +} + +fn unique_line_start(source: &str, needle: &str, anchored: bool) -> Result { + let mut offset = 0; + let mut result = None; + for line in source.split_inclusive('\n') { + let code = unescaped_comment(line).trim(); + let found = if anchored { + code.starts_with(needle) + } else { + code.contains(needle) + }; + if found { + if result.is_some() { + return Err(format!( + "Multiple {needle} occurrences; edit source explicitly" + )); + } + result = Some(offset); + } + offset += line.len(); + } + result.ok_or_else(|| format!("No unambiguous {needle}; edit source explicitly")) +} + +fn insertion_offset(source: &str) -> Result { + let _begin = unique_line_start(source, r"\begin{tikzpicture}", true)?; + let end = unique_line_start(source, r"\end{tikzpicture}", true)?; + if end <= _begin { + return Err("Invalid TikZ picture order".into()); + } + Ok(end) +} + +fn eol(source: &str) -> &'static str { + if source.contains("\r\n") { + "\r\n" + } else { + "\n" + } +} + +fn source_fingerprint(source: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + source.hash(&mut hasher); + hasher.finish() +} + +/// The inverse is an exact source-range patch from C. A whole-source +/// fingerprint refuses undo after manual changes (without restoring a file). +#[derive(Clone, Debug)] +struct Patch { + inverse: SourcePatch, + after_fingerprint: u64, +} +fn minimal_changed_span( + before: &str, + after: &str, + at: usize, + rev: u64, +) -> Result { + if before == after { + return Err("No parameter change".into()); + } + let prefix: usize = before + .chars() + .zip(after.chars()) + .take_while(|(a, b)| a == b) + .map(|(c, _)| c.len_utf8()) + .sum(); + let mut old_end = before.len(); + let mut new_end = after.len(); + while old_end > prefix && new_end > prefix { + let a = before[..old_end] + .chars() + .next_back() + .ok_or("Invalid old UTF-8")?; + let b = after[..new_end] + .chars() + .next_back() + .ok_or("Invalid new UTF-8")?; + if a != b { + break; + } + old_end -= a.len_utf8(); + new_end -= b.len_utf8(); + } + Ok(SourcePatch { + range: at + prefix..at + old_end, + expected_old: before[prefix..old_end].to_owned(), + replacement: after[prefix..new_end].to_owned(), + source_revision: rev, + }) +} + +#[derive(Default)] +pub struct Session { + pub active: Option, + pub selected: Option, + pub draft: Draft, + pub drag_start: Option, + pub status: String, + pub help: bool, + next_id: u64, + revision: u64, + source_fingerprint: u64, + undo: Vec, +} +impl Session { + pub fn reset(&mut self, source: &str) { + self.active = None; + self.selected = None; + self.draft = Draft::default(); + self.drag_start = None; + self.undo.clear(); + self.revision = 0; + self.source_fingerprint = source_fingerprint(source); + self.next_id = owned(source) + .iter() + .map(|item| item.id) + .max() + .unwrap_or(0) + .saturating_add(1) + .max(1); + self.status = "Source-first: unknown TeX and macros remain unchanged".into(); + } + /// Direct Source edits never get rewritten; they invalidate transient undo. + fn adopt_external(&mut self, source: &str) { + let current = source_fingerprint(source); + if current != self.source_fingerprint { + self.revision = self.revision.saturating_add(1); + self.source_fingerprint = current; + self.undo.clear(); + self.selected = None; + self.next_id = owned(source) + .iter() + .map(|item| item.id) + .max() + .unwrap_or(0) + .saturating_add(1) + .max(1); + self.status = "Source edited manually; previous visual undo invalidated".into(); + } + } + pub fn note_manual_edit(&mut self, source: &str) { + self.adopt_external(source); + } + fn apply_patch(&mut self, source: &mut String, patch: SourcePatch) -> Result<(), String> { + if self.source_fingerprint != source_fingerprint(source) { + return Err("Source changed after visual inspection; patch refused".into()); + } + let inverse = patch + .apply(source, &mut self.revision) + .map_err(|e| e.to_string())?; + self.source_fingerprint = source_fingerprint(source); + self.undo.push(Patch { + inverse, + after_fingerprint: self.source_fingerprint, + }); + Ok(()) + } + pub fn undo_available(&self) -> bool { + !self.undo.is_empty() + } + pub fn choose(&mut self, source: &str, id: u64) -> Result { + self.adopt_external(source); + let item = one_owned(source, id)?; + self.selected = Some(id); + self.active = Some(item.tool); + self.draft = item.draft; + Ok(item.line) + } + pub fn insert( + &mut self, + source: &mut String, + tool: Tool, + draft: Draft, + ) -> Result { + self.adopt_external(source); + if !tikz_loaded(source) { + return Err("TikZ not explicitly loaded; approve the visible \\usepackage diff".into()); + } + for library in &spec(tool).requirements.libraries { + if !library_loaded(source, library) { + return Err(format!( + "Missing project \\usetikzlibrary{{{library}}}; review source diff first" + )); + } + } + let at = insertion_offset(source)?; + let id = self.next_id.max(1); + if source.contains(&format!("{MARKER}{id}:")) { + return Err("Ownership ID conflict".into()); + } + let line = generate(tool, &draft, id)?; + let after = format!("{}{}", line, eol(source)); + let number = source[..at].bytes().filter(|b| *b == b'\n').count() + 1; + self.apply_patch( + source, + SourcePatch { + range: at..at, + expected_old: String::new(), + replacement: after, + source_revision: self.revision, + }, + )?; + self.next_id = id.saturating_add(1); + self.active = Some(tool); + self.selected = Some(id); + self.draft = draft; + self.status = format!( + "Created {} with C catalogue · Build XeLaTeX for exact PDF", + tool.id() + ); + Ok(number) + } + pub fn apply_parameters(&mut self, source: &mut String) -> Result { + self.adopt_external(source); + let id = self.selected.ok_or("Select a C-backed B08 command first")?; + let old = one_owned(source, id)?; + let after = generate(old.tool, &self.draft, id)?; + if old.text != after { + let patch = minimal_changed_span(&old.text, &after, old.range.start, self.revision)?; + self.apply_patch(source, patch)?; + } + self.status = "Edited only generated command parameters; PDF requires XeLaTeX Build".into(); + Ok(old.line) + } + pub fn propose_usepackage(source: &str) -> Result { + if tikz_loaded(source) { + return Err("TikZ is already explicitly loaded".into()); + } + unique_line_start(source, r"\begin{document}", true)?; + Ok(format!("+ \\usepackage{{tikz}}{}", eol(source))) + } + pub fn add_usepackage(&mut self, source: &mut String) -> Result<(), String> { + self.adopt_external(source); + Self::propose_usepackage(source)?; + let at = unique_line_start(source, r"\begin{document}", true)?; + let after = format!("\\usepackage{{tikz}}{}", eol(source)); + self.apply_patch( + source, + SourcePatch { + range: at..at, + expected_old: String::new(), + replacement: after, + source_revision: self.revision, + }, + )?; + self.status = "Added \\usepackage{tikz} via visible diff; Undo available".into(); + Ok(()) + } + pub fn propose_library(source: &str, library: &str) -> Result { + if library != "arrows.meta" { + return Err( + "Only C-catalogue arrows.meta is supported in this B visual adapter".into(), + ); + } + if library_loaded(source, library) { + return Err(format!("Library {library} already loaded")); + } + unique_line_start(source, r"\begin{document}", true)?; + Ok(format!("+ \\usetikzlibrary{{{library}}}{}", eol(source))) + } + pub fn add_library(&mut self, source: &mut String, library: &str) -> Result<(), String> { + self.adopt_external(source); + Self::propose_library(source, library)?; + let at = unique_line_start(source, r"\begin{document}", true)?; + let after = format!("\\usetikzlibrary{{{library}}}{}", eol(source)); + self.apply_patch( + source, + SourcePatch { + range: at..at, + expected_old: String::new(), + replacement: after, + source_revision: self.revision, + }, + )?; + self.status = format!("Added \\usetikzlibrary{{{library}}}; Undo available"); + Ok(()) + } + pub fn undo(&mut self, source: &mut String) -> Result<(), String> { + let patch = self.undo.last().ok_or("No visual patch to undo")?.clone(); + if source_fingerprint(source) != patch.after_fingerprint { + return Err("Undo refused: source edited since visual patch".into()); + } + patch + .inverse + .apply(source, &mut self.revision) + .map_err(|e| e.to_string())?; + self.undo.pop(); + self.source_fingerprint = source_fingerprint(source); + if let Some(id) = self.selected { + if let Ok(current) = one_owned(source, id) { + self.active = Some(current.tool); + self.draft = current.draft; + } else { + self.selected = None; + self.draft = Draft::default(); + } + } + self.status = "Undo: reverted exact C-validated source range".into(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + const SOURCE: &str = "\\documentclass{standalone}\n\\usepackage{tikz}\n\\usetikzlibrary{arrows.meta}\n\\begin{document}\n\\begin{tikzpicture}\n% user opaque code\n\\myMacro{x}\n\\end{tikzpicture}\n\\end{document}\n"; + + #[test] + fn all_six_tools_generate_reparse_and_preserve_other_source() { + for tool in TOOLS { + let mut src = SOURCE.to_owned(); + let mut session = Session::default(); + session.reset(&src); + let line = session.insert(&mut src, tool, Draft::default()).unwrap(); + assert_eq!(line, 8); + assert!(src.contains("% user opaque code\n\\myMacro{x}\n")); + assert_eq!(owned(&src).len(), 1, "failed to roundtrip {}", tool.id()); + assert!(src.contains(spec(tool).syntax.split_whitespace().next().unwrap())); + session.undo(&mut src).unwrap(); + assert_eq!(src, SOURCE); + } + } + + #[test] + fn parameter_patch_is_only_one_line_and_can_be_undone_twice() { + let mut src = SOURCE.to_owned(); + let mut session = Session::default(); + session.reset(&src); + session + .insert(&mut src, Tool::Rectangle, Draft::default()) + .unwrap(); + let inserted = src.clone(); + session.draft.end.x = 5.25; + session.apply_parameters(&mut src).unwrap(); + assert!(src.contains("rectangle (5.25cm,2.00cm)")); + assert!(src.contains("\\myMacro{x}")); + session.undo(&mut src).unwrap(); + assert_eq!(src, inserted); + assert_eq!(session.selected, Some(1)); + assert_eq!(session.draft.end.x, 3.0); + session.undo(&mut src).unwrap(); + assert_eq!(src, SOURCE); + } + + #[test] + fn reopening_source_can_select_owned_line_without_rewriting_it() { + let mut src = SOURCE.to_owned(); + let mut session = Session::default(); + session.reset(&src); + session + .insert(&mut src, Tool::Circle, Draft::default()) + .unwrap(); + let before = src.clone(); + let mut reopened = Session::default(); + reopened.reset(&src); + assert_eq!(reopened.choose(&src, 1).unwrap(), 7); + assert_eq!(reopened.active, Some(Tool::Circle)); + assert_eq!(src, before); + } + + #[test] + fn manually_edited_or_duplicated_marker_fails_closed() { + let mut src = SOURCE.to_owned(); + let mut session = Session::default(); + session.reset(&src); + session + .insert(&mut src, Tool::Line, Draft::default()) + .unwrap(); + src = src.replace(r"\draw (1.00cm,1.00cm)", r"\draw[red] (1.00cm,1.00cm)"); + assert!(session.apply_parameters(&mut src).is_err()); + assert!(session.undo(&mut src).is_err()); + let mut clean = SOURCE.to_owned(); + session.reset(&clean); + session + .insert(&mut clean, Tool::Line, Draft::default()) + .unwrap(); + let line = owned(&clean)[0].text.clone(); + clean.push_str(&format!("{line}\n")); + assert!(session.apply_parameters(&mut clean).is_err()); + } + + #[test] + fn source_with_two_pictures_or_no_preamble_never_gets_rewritten() { + let mut src = SOURCE.replace( + "\\end{document}", + "\\begin{tikzpicture}\n\\end{tikzpicture}\n\\end{document}", + ); + let before = src.clone(); + let mut session = Session::default(); + assert!( + session + .insert(&mut src, Tool::Node, Draft::default()) + .is_err() + ); + assert_eq!(src, before); + let mut unknown = "\\begin{document}\n\\myMacro\n\\end{document}\n".to_owned(); + let original = unknown.clone(); + assert!( + session + .insert(&mut unknown, Tool::Line, Draft::default()) + .is_err() + ); + assert_eq!(unknown, original); + } + + #[test] + fn explicit_usepackage_diff_crlf_and_undo_preserve_original_bytes() { + let mut src = "\\documentclass{standalone}\r\n\\begin{document}\r\n\\begin{tikzpicture}\r\n\\end{tikzpicture}\r\n\\end{document}\r\n".to_owned(); + let before = src.clone(); + let mut session = Session::default(); + assert!( + Session::propose_usepackage(&src) + .unwrap() + .contains("+ \\usepackage{tikz}") + ); + session.add_usepackage(&mut src).unwrap(); + assert!(tikz_loaded(&src)); + assert!(src.contains("\\usepackage{tikz}\r\n")); + session.undo(&mut src).unwrap(); + assert_eq!(src, before); + } + + #[test] + fn escaped_and_commented_packages_are_not_loaded() { + assert!(!tikz_loaded("% \\usepackage{tikz}\n\\% \\usepackage{x}")); + assert!(tikz_loaded("\\usepackage{tikz}\n")); + } + + #[test] + fn node_text_escaping_and_invalid_numbers_are_checked() { + let mut draft = Draft { + text: r"a_b".to_owned(), + ..Default::default() + }; + assert!(generate(Tool::Node, &draft, 1).unwrap().contains(r"a\_b")); + draft.text = "Привет".into(); + assert!(generate(Tool::Node, &draft, 1).is_ok()); + draft.start.x = f32::NAN; + assert!(generate(Tool::Line, &draft, 1).is_err()); + } + #[test] + fn c_catalog_is_only_source_of_tool_commands_and_help() { + let cat = catalog(); + for tool in TOOLS { + let draft = Draft::default(); + let values = parameter_values(tool, &draft).unwrap(); + let command = cat.generate(tool.catalog_id(), &values).unwrap(); + assert_eq!( + generate(tool, &draft, 9).unwrap(), + format!(" {}{MARKER}9:{}", command.source, tool.id()) + ); + let help = spec(tool); + assert!(!help.help_ru.is_empty()); + assert!(!help.manual_section.is_empty()); + assert!(!help.parameter.is_empty()); + assert!(help.requirements.packages.iter().any(|x| x == "tikz")); + } + } + + #[test] + fn arrow_needs_explicit_arrows_meta_and_diff_is_reversible() { + let mut source = SOURCE.replace("\\usetikzlibrary{arrows.meta}\n", ""); + let original = source.clone(); + let mut session = Session::default(); + session.reset(&source); + assert!(!library_loaded(&source, "arrows.meta")); + assert!( + session + .insert(&mut source, Tool::Arrow, Draft::default()) + .is_err() + ); + let diff = Session::propose_library(&source, "arrows.meta").unwrap(); + assert!(diff.contains("+ \\usetikzlibrary{arrows.meta}")); + session.add_library(&mut source, "arrows.meta").unwrap(); + assert!(library_loaded(&source, "arrows.meta")); + session.undo(&mut source).unwrap(); + assert_eq!(source, original); + } + + #[test] + fn literal_imports_and_opaque_macro_imports_remain_distinct() { + assert!(tikz_loaded(r"\usepackage[utf8]{graphics,tikz,amsmath}")); + assert!(library_loaded( + r"\usetikzlibrary{calc,arrows.meta}", + "arrows.meta" + )); + assert!(!library_loaded( + "% \\usetikzlibrary{arrows.meta}", + "arrows.meta" + )); + assert!(!tikz_loaded(r"\myLoadTikZ")); + } + + #[test] + fn c_owned_node_math_and_cyrillic_roundtrip_through_source() { + let mut source = SOURCE.to_owned(); + let mut session = Session::default(); + session.reset(&source); + let math = Draft { + text: r"\alpha+\beta".into(), + mode: "math".into(), + ..Default::default() + }; + session.insert(&mut source, Tool::Node, math).unwrap(); + assert!(source.contains("$\\alpha+\\beta$")); + assert_eq!(owned(&source).len(), 1); + session.undo(&mut source).unwrap(); + assert_eq!(source, SOURCE); + let text = Draft { + text: "Привет_мир".into(), + ..Default::default() + }; + session.insert(&mut source, Tool::Node, text).unwrap(); + assert!(source.contains(r"Привет\_мир")); + assert_eq!(owned(&source).len(), 1); + session.undo(&mut source).unwrap(); + assert_eq!(source, SOURCE); + } + + #[test] + fn shortest_parameter_patch_changes_no_unrelated_coordinates() { + let mut source = SOURCE.replace("% user opaque code\n", "% user opaque code\nx=5.25cm\n"); + let mut session = Session::default(); + session.reset(&source); + session + .insert(&mut source, Tool::Rectangle, Draft::default()) + .unwrap(); + let created = source.clone(); + session.draft.end.x = 5.25; + session.apply_parameters(&mut source).unwrap(); + assert!(source.contains("rectangle (5.25cm,2.00cm)")); + assert!(source.contains("x=5.25cm\n")); + session.undo(&mut source).unwrap(); + assert_eq!(source, created); + session.undo(&mut source).unwrap(); + assert!(source.contains("% user opaque code\nx=5.25cm\n")); + } + + #[test] + fn manual_source_edit_invalidates_visual_undo_without_rewriting_it() { + let mut source = SOURCE.to_owned(); + let mut session = Session::default(); + session.reset(&source); + session + .insert(&mut source, Tool::Circle, Draft::default()) + .unwrap(); + source.push_str("% user's final source edit\n"); + assert!(session.undo(&mut source).is_err()); + let edited = source.clone(); + session.note_manual_edit(&source); + assert!(!session.undo_available()); + assert_eq!(source, edited); + } +} diff --git a/crates/tikzarium-app/src/tikz_canvas.rs b/crates/tikzarium-app/src/tikz_canvas.rs new file mode 100644 index 00000000..0af8d416 --- /dev/null +++ b/crates/tikzarium-app/src/tikz_canvas.rs @@ -0,0 +1,493 @@ +//! B07 egui-only interaction for an independent approximate coordinate pad. +//! Exact output is the XeLaTeX PDF below this pad, NEVER the painted guides. + +use crate::tikz_authoring::{self, Draft, Point, Session, TOOLS, Tool}; +use eframe::egui::{self, Color32, Pos2, Rect, Sense, Stroke, Vec2}; + +#[derive(Clone, Copy)] +pub struct Capabilities { + pub installed: bool, + pub loaded: bool, + pub engine_compatible: bool, + pub implemented: bool, + pub arrow_library_loaded: bool, +} + +impl Capabilities { + pub fn can_draw(self) -> bool { + self.installed && self.loaded && self.engine_compatible && self.implemented + } + pub fn can_draw_tool(self, tool: Tool) -> bool { + self.can_draw() && (tool != Tool::Arrow || self.arrow_library_loaded) + } + pub fn explanation_for(self, tool: Tool) -> &'static str { + if tool == Tool::Arrow && !self.arrow_library_loaded { + "arrows.meta is not explicitly loaded in this project; approve its visible preamble diff." + } else { + self.explanation() + } + } + pub fn explanation(self) -> &'static str { + if !self.engine_compatible { + "XeLaTeX unavailable or incompatible (configure an external engine)." + } else if !self.installed { + "tikz.sty not confirmed installed. Install MiKTeX/TeX Live independently; no automatic installation." + } else if !self.loaded { + "TikZ located, but \\usepackage{tikz} not explicitly loaded. Review the proposed source diff." + } else if !self.implemented { + "No reviewed B visual source adapter available." + } else { + "Six native TikZ core source adapters are available; package compilation is still NOT_VERIFIED." + } + } +} + +const SCALE: f32 = 29.0; +const MARGIN: f32 = 14.0; +const HEIGHT: f32 = 176.0; +fn screen_point(rect: Rect, p: Point) -> Pos2 { + Pos2::new( + rect.left() + MARGIN + p.x * SCALE, + rect.bottom() - MARGIN - p.y * SCALE, + ) +} +fn snap(pos: Pos2, rect: Rect) -> Point { + let x = ((pos.x - rect.left() - MARGIN) / SCALE).max(0.0); + let y = ((rect.bottom() - MARGIN - pos.y) / SCALE).max(0.0); + Point::new((x * 4.0).round() / 4.0, (y * 4.0).round() / 4.0) +} +fn paint_draft(painter: &egui::Painter, rect: Rect, tool: Tool, draft: &Draft) { + let blue = Color32::from_rgb(50, 158, 222); + let a = screen_point(rect, draft.start); + let b = screen_point(rect, draft.end); + let stroke = Stroke::new(2.0, blue); + match tool { + Tool::Line | Tool::Arrow => { + painter.line_segment([a, b], stroke); + if tool == Tool::Arrow { + let dir = b - a; + let length = dir.length(); + if length > 0.1 { + let unit = dir / length; + let tail = b - unit * 11.0; + let sideways = Vec2::new(-unit.y, unit.x) * 4.0; + painter.line_segment([b, tail + sideways], stroke); + painter.line_segment([b, tail - sideways], stroke); + } + } + } + Tool::Rectangle => { + painter.rect_stroke( + Rect::from_two_pos(a, b), + 0.0, + stroke, + egui::StrokeKind::Middle, + ); + } + Tool::Circle => { + painter.circle_stroke(a, (b - a).length().max(0.1), stroke); + } + Tool::Node => { + painter.circle_stroke(a, 5.0, stroke); + painter.text( + a + Vec2::new(8.0, -10.0), + egui::Align2::LEFT_TOP, + &draft.text, + egui::FontId::proportional(12.0), + blue, + ); + } + Tool::Cubic => { + let dx = (draft.end.x - draft.start.x) / 3.0; + let c1 = screen_point( + rect, + Point::new(draft.start.x + dx, draft.start.y + draft.bend), + ); + let c2 = screen_point(rect, Point::new(draft.end.x - dx, draft.end.y + draft.bend)); + painter.line_segment([a, c1], Stroke::new(1.0, blue)); + painter.line_segment([c2, b], Stroke::new(1.0, blue)); + let cubic = |t: f32| { + let u = 1.0 - t; + let t2 = t * t; + let u2 = u * u; + Pos2::new( + u2 * u * a.x + 3.0 * u2 * t * c1.x + 3.0 * u * t2 * c2.x + t2 * t * b.x, + u2 * u * a.y + 3.0 * u2 * t * c1.y + 3.0 * u * t2 * c2.y + t2 * t * b.y, + ) + }; + let mut old = a; + for step in 1..=20 { + let point = cubic(step as f32 / 20.0); + painter.line_segment([old, point], stroke); + old = point; + } + painter.circle_filled(c1, 3.0, blue); + painter.circle_filled(c2, 3.0, blue); + } + } + painter.circle_filled(a, 3.5, blue); + if tool != Tool::Node { + painter.circle_filled(b, 3.5, blue); + } +} + +pub fn show_guide( + ui: &mut egui::Ui, + session: &mut Session, + source: &mut String, + caps: Capabilities, +) -> Option { + ui.horizontal_wrapped(|ui| { + ui.strong("TikZ primitives"); + for tool in TOOLS { + let info = tikz_authoring::spec(tool); + let response = ui + .add_enabled( + caps.can_draw_tool(tool), + egui::Button::new(&info.title_ru).selected(session.active == Some(tool)), + ) + .on_hover_text(format!( + "{}\n{}\n{}", + info.help_ru, info.syntax, info.manual_section + )); + if response.clicked() { + session.active = Some(tool); + session.selected = None; + session.help = true; + } + } + }); + if !caps.can_draw() { + ui.colored_label(ui.visuals().warn_fg_color, caps.explanation()); + } + ui.small("СКЕТЧ-СЕТКА · приблизительные координаты 0.25 cm, НЕ совмещена с PDF и не отражает arbitrary scopes/transforms."); + let width = ui.available_width().max(180.0); + let (rect, response) = + ui.allocate_exact_size(Vec2::new(width, HEIGHT), Sense::click_and_drag()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 3.0, ui.visuals().faint_bg_color); + let grid_color = ui + .visuals() + .widgets + .noninteractive + .bg_stroke + .color + .gamma_multiply(0.55); + let vertical = ((rect.width() - 2.0 * MARGIN) / SCALE).floor() as usize; + let horizontal = ((rect.height() - 2.0 * MARGIN) / SCALE).floor() as usize; + for x in 0..=vertical { + let screen_x = rect.left() + MARGIN + x as f32 * SCALE; + painter.line_segment( + [ + Pos2::new(screen_x, rect.top()), + Pos2::new(screen_x, rect.bottom()), + ], + Stroke::new(0.6, grid_color), + ); + } + for y in 0..=horizontal { + let screen_y = rect.bottom() - MARGIN - y as f32 * SCALE; + painter.line_segment( + [ + Pos2::new(rect.left(), screen_y), + Pos2::new(rect.right(), screen_y), + ], + Stroke::new(0.6, grid_color), + ); + } + painter.text( + rect.left_top() + Vec2::new(8.0, 6.0), + egui::Align2::LEFT_TOP, + "Guide only · x/y TikZ units assumed 1 cm", + egui::FontId::monospace(10.0), + ui.visuals().text_color(), + ); + + if let Some(tool) = session.active { + if let Some(start) = session.drag_start { + let end = response + .interact_pointer_pos() + .map(|pos| snap(pos, rect)) + .unwrap_or(start); + let mut draft = session.draft.clone(); + draft.start = start; + draft.end = end; + paint_draft(&painter, rect, tool, &draft); + } else if session.selected.is_some() { + paint_draft(&painter, rect, tool, &session.draft); + } + } + + if session.active.is_none() { + return None; + } + if let Some(tool) = session.active + && !caps.can_draw_tool(tool) + { + ui.colored_label(ui.visuals().warn_fg_color, caps.explanation_for(tool)); + return None; + } + if response.drag_started() + && let Some(pointer) = response.interact_pointer_pos() + { + session.drag_start = Some(snap(pointer, rect)); + } + let mut finish = None; + if response.drag_stopped() { + if let Some(start) = session.drag_start.take() { + let end = response + .interact_pointer_pos() + .map(|pos| snap(pos, rect)) + .unwrap_or(start); + finish = Some((start, end)); + } + } else if response.clicked() + && let Some(pointer) = response.interact_pointer_pos() + { + let start = snap(pointer, rect); + let end = if session.active == Some(Tool::Circle) { + Point::new(start.x + 1.0, start.y) + } else { + Point::new(start.x + 1.0, start.y + 1.0) + }; + finish = Some((start, end)); + } + if let (Some(tool), Some((start, end))) = (session.active, finish) { + let draft = Draft { + start, + end, + ..Default::default() + }; + match session.insert(source, tool, draft) { + Ok(line) => return Some(line), + Err(error) => session.status = error, + } + } + None +} + +pub fn show_inspector( + ui: &mut egui::Ui, + session: &mut Session, + source: &mut String, + caps: Capabilities, +) -> Option { + let mut jump = None; + ui.heading("TikZ · свойства"); + ui.small("Только собственные B08-команды каталога C. Чужой TeX остаётся в Source."); + ui.small(format!( + "PGF/TikZ: installed={} · project-loaded={} · XeLaTeX-compatible={} · B-adapter={} · arrows.meta-loaded={}", + caps.installed, caps.loaded, caps.engine_compatible, caps.implemented, caps.arrow_library_loaded, + )); + ui.label(caps.explanation()); + ui.separator(); + + let choices = tikz_authoring::owned(source); + egui::ComboBox::from_id_salt("tikz-b08-owned") + .selected_text(match session.selected { + Some(id) => format!("Primitive #{id}"), + None => "Выберите примитив".into(), + }) + .show_ui(ui, |ui| { + for item in choices { + let label = format!( + "#{} {} · line {}", + item.id, + tikz_authoring::spec(item.tool).title_ru, + item.line + ); + if ui + .selectable_label(session.selected == Some(item.id), label) + .clicked() + { + match session.choose(source, item.id) { + Ok(line) => jump = Some(line), + Err(error) => session.status = error, + } + } + } + }); + + if let Some(tool) = session.active { + let info = tikz_authoring::spec(tool); + ui.separator(); + ui.strong(&info.title_ru); + ui.label("Точки (условные TikZ x/y, cm для обычной picture):"); + ui.horizontal(|ui| { + ui.label("x1"); + ui.add(egui::DragValue::new(&mut session.draft.start.x).speed(0.1)); + ui.label("y1"); + ui.add(egui::DragValue::new(&mut session.draft.start.y).speed(0.1)); + }); + if tool == Tool::Circle { + let mut radius = (session.draft.end.x - session.draft.start.x) + .hypot(session.draft.end.y - session.draft.start.y); + ui.horizontal(|ui| { + ui.label("radius (cm)"); + if ui + .add(egui::DragValue::new(&mut radius).speed(0.1)) + .changed() + { + let radius = radius.clamp(0.1, 1000.0); + session.draft.end = + Point::new(session.draft.start.x + radius, session.draft.start.y); + } + }); + } else if tool != Tool::Node { + ui.horizontal(|ui| { + ui.label("x2"); + ui.add(egui::DragValue::new(&mut session.draft.end.x).speed(0.1)); + ui.label("y2"); + ui.add(egui::DragValue::new(&mut session.draft.end.y).speed(0.1)); + }); + } + if tool == Tool::Cubic { + ui.horizontal(|ui| { + ui.label("bend"); + ui.add(egui::DragValue::new(&mut session.draft.bend).speed(0.1)); + ui.label("cm"); + }); + } + if tool == Tool::Arrow { + if let Some(tip) = info.parameter.iter().find(|p| p.id == "tip") { + ui.label(&tip.help_ru); + egui::ComboBox::from_id_salt("c-arrow-tip") + .selected_text(&session.draft.tip) + .show_ui(ui, |ui| { + for option in &tip.allowed_values { + ui.selectable_value(&mut session.draft.tip, option.clone(), option); + } + }); + } + } + if tool == Tool::Node { + if let Some(mode) = info.parameter.iter().find(|p| p.id == "mode") { + ui.label(&mode.help_ru); + egui::ComboBox::from_id_salt("c-node-mode") + .selected_text(&session.draft.mode) + .show_ui(ui, |ui| { + for option in &mode.allowed_values { + ui.selectable_value(&mut session.draft.mode, option.clone(), option); + } + }); + } + ui.label("Содержимое: text экранируется C, math — исходное TeX-выражение"); + ui.text_edit_singleline(&mut session.draft.text); + } + if let Some(id) = session.selected { + if ui.button("Применить · patch source").clicked() { + match session.apply_parameters(source) { + Ok(line) => jump = Some(line), + Err(error) => session.status = error, + } + } + ui.small(format!("C-catalogue source marker: B08 #{id}")); + } + if let Ok(command) = + tikz_authoring::generate(tool, &session.draft, session.selected.unwrap_or(0)) + { + ui.label("Генерируемый код:"); + ui.code(command); + } + if ui + .button(if session.help { + "Скрыть справку F1" + } else { + "Справка F1" + }) + .clicked() + { + session.help = !session.help; + } + if session.help { + ui.separator(); + ui.strong("Контекстная справка · каталог C"); + ui.label(&info.help_ru); + ui.small(format!("Синтаксис: {}", info.syntax)); + ui.small(format!("Интерактивно: {}", info.interaction_ru)); + ui.small(format!("Предупреждение: {}", info.warning_ru)); + ui.code(&info.example); + ui.small(format!( + "Зависимости: пакеты {:?}, библиотеки {:?}, движок {}, documented PGF {}, минимальная версия {}", + info.requirements.packages, info.requirements.libraries, + info.requirements.engine, info.requirements.documented_against, + info.requirements.pgf_version_min + )); + ui.small(format!( + "Первоисточник: {} · {}", + info.manual_section, info.manual_url + )); + for parameter in &info.parameter { + ui.collapsing(format!("{} · {}", parameter.id, parameter.kind), |ui| { + ui.label(¶meter.help_ru); + ui.small(format!("Синтаксис: {}", parameter.syntax)); + ui.small(format!( + "Единицы {:?}; допустимые значения {:?}", + parameter.units, parameter.allowed_values + )); + ui.small(format!( + "Default: {} ({})", + parameter.default, parameter.default_provenance + )); + ui.small(format!("Предупреждение: {}", parameter.warning_ru)); + ui.small(format!("Взаимодействие: {}", parameter.interaction_ru)); + ui.code(¶meter.example); + ui.small(format!( + "PGF Manual: {} · {}", + parameter.manual_section, parameter.manual_url + )); + }); + } + } + } else { + ui.small("Выберите инструмент над координатным полем, затем click/drag. F1 показывает справку для выбранной команды."); + } + + if caps.installed && !caps.loaded { + ui.separator(); + ui.strong("Explicit package diff (Undo supported)"); + match Session::propose_usepackage(source) { + Ok(diff) => { + ui.code(diff); + if ui.button("Принять diff").clicked() + && let Err(error) = session.add_usepackage(source) + { + session.status = error; + } + } + Err(error) => { + ui.small(error); + } + } + } + if caps.installed && caps.loaded && !caps.arrow_library_loaded { + ui.separator(); + ui.strong("arrows.meta · явная правка преамбулы с Undo"); + match Session::propose_library(source, "arrows.meta") { + Ok(diff) => { + ui.code(diff); + if ui.button("Подключить arrows.meta").clicked() + && let Err(error) = session.add_library(source, "arrows.meta") + { + session.status = error; + } + } + Err(error) => { + ui.small(error); + } + } + } + if ui + .add_enabled( + session.undo_available(), + egui::Button::new("↶ Undo visual patch"), + ) + .clicked() + && let Err(error) = session.undo(source) + { + session.status = error; + } + ui.separator(); + ui.small(&session.status); + jump +} diff --git a/insights/2026-10-31_B07_TIKZ_NATIVE_VERTICAL_QA.md b/insights/2026-10-31_B07_TIKZ_NATIVE_VERTICAL_QA.md new file mode 100644 index 00000000..724bde41 --- /dev/null +++ b/insights/2026-10-31_B07_TIKZ_NATIVE_VERTICAL_QA.md @@ -0,0 +1,26 @@ +# B07 evidence and handoff (2026-10-31) + +Baseline main: c5c8c1576b6288e1d125d558d0d4872b321efb86. Checked AGENTS, main, open PRs, C registry, B06 plans/insights. B PR scope: only crates/tikzarium-app, B07 plans/insights. No FlowBridge/WhereIsIt or other lanes. + +## Executable work +- App-owned Rust model generates six native TikZ path/node forms and exact-tagged source lines, performs narrow patch/undo, rereads saved source tags, rejects changed/duplicate ownership and ambiguous pictures, and offers explicit package diff. +- Egui canvas contains separate approximate quarter-grid pad above existing exact XeLaTeX PDF, with toolbar and inspector, parameter Apply, generated code, Russian help and F1/hover. Source editor jump highlights selected generated line. Theme controls remain app-only. +- C provides existing installation/engine data. C does NOT yet provide a versioned primitive catalogue; its visual_features remains empty and compilation not verified. A separate C schema handoff was posted to issue #4; no C mutation. + +## Evidence ledger +| Gate | Status | +| --- | --- | +| GitHub main SHA and AGENTS/open PR preflight | VERIFIED | +| Source code/docs/fixture committed to isolated B branch | COMMITTED, Rust quality not yet verified | +| cargo fmt / cargo test / cargo clippy locally | NOT_RUN: no Rust toolchain in execution container | +| Hosted exact-head GitHub workflows | PENDING until PR | +| Real fixture XeLaTeX/Poppler execution | NOT_RUN | +| Windows 10 x64 MiKTeX native GUI | NOT_RUN | +| Physical macOS M1/M4 GUI | NOT_RUN | +| Screenshots, PDF SHA, exact help section audit | NOT_RUN | +| C-owned primitive generator integration | BLOCKED by missing C schema, interim B table documented | + +## Risks +Egui 0.33 APIs and fmt/clippy must be verified on CI, not asserted. Literal usepackage detection intentionally conservative; classes, aliases, dynamic source and multiple scopes remain source-first. The pad assumes 1cm vectors at origin but transformations/options can invalidate any PDF alignment; the label explicitly forbids interpreting it as exact. Source undo covers session-generated patches only; source edited manually after a patch may force refusal. Reopen selects exact generated tagged lines, not arbitrary TikZ. + +Integrator handoff: review B draft after exact SHA CI and C issue coordination. D must distinguish hosted Windows Server/macOS CI from physical Win10/M1/M4 acceptance. Source-saving conflict protections are in integration PR #33, not B07. diff --git a/insights/2026-10-31_B08_CATALOG_INTEGRATION_QA.md b/insights/2026-10-31_B08_CATALOG_INTEGRATION_QA.md new file mode 100644 index 00000000..ed180cf0 --- /dev/null +++ b/insights/2026-10-31_B08_CATALOG_INTEGRATION_QA.md @@ -0,0 +1,37 @@ +# B08 — catalog-driven authoring integration QA and evidence + +Date: 2026-10-31. Baseline main observed 0aa78ae22df0ae84705ba461f017fce256325875. Read AGENTS.md, corrected full-scope plan and benchmark insight, and open PRs #26/#27/#28/#34/#36/#37/#38/#39/#40/#41/#42. C07 head f5e31c54604d567bcfd8c84eb84cbc7c1ad821ac; B07 head 12bdd0840f197d3c46ba8817887fe1a8b6c196c2. + +## Implemented in B branch + +- Replaced B07's six-source-generator/help table with C07's existing one-schema Catalog. Palette labels/hover, F1/inspector, parameter typing/units/default provenance/manual reference and generated-source commands all refer to C data. +- B-owned gestures and approximate guide retained, but native TikZ code now generated with explicit cm units and C arrow/node modes. Only app-owned generated source commands are recognized for re-edit; unknown source remains untouched. +- Minimal, version-checked C SourcePatch for insert, parameter edit, explicit preamble activation and undo. Undo refuses direct Source edits; deterministic reopen recognition for supported commands. +- Corrected B07 rustfmt radius line. Separate B08 regression tests cover all six commands/catalog parity, math/text Unicode, explicit arrows.meta, direct Source conflicts and unrelated bytes. + +## Real external TeX fixture result (not GUI) + +Local Debian host: /usr/bin/xelatex with -no-shell-escape, -interaction=nonstopmode -halt-on-error -file-line-error. +One-page manually assembled B08-compatible TeX under DejaVu Serif and fontspec, including six primitives, arrows.meta Stealth, Cyrillic escaped underscore and a user-defined TikZ style. +**XeLaTeX process: PASS exit 0; PDF produced and rasterized (pdftoppm); visually inspected**. Input SHA-256 6fe3a8c198fc5b9e178506b9ba34bccd11ec8b5e538b709dd533bce71c240d8e; PDF SHA-256 f781c316debb0b1b4505e31bfc6cb65107bf6a6dbb653b2970725bb9eafced0. The independent B08 fixture stored in the PR is portable ASCII and must be tested separately in CI; hashes refer only to the local proof, not to that repository fixture. + +Earlier local trial erroneously used a control sequence as a TikZ key and failed; it was replaced by a valid \tikzset style and is NOT counted as a product defect. Local TeX proof does not prove compiler execution from GUI, C Rust output parity, or physical Win10/M1/M4 behavior. + +## CI evidence and blockers + +B07 PR #37 on SHA 12bdd084: Rust CI FAIL because cargo fmt --check found a radius assignment reflow in tikz_canvas.rs; fixed in B08. B07 font workflow PASS, D QA still queued at preflight. C07 PR #39 generated source has suspicious raw source text for math mode/escape ('$' char missing in the fetched source); exact Rust CI pending and must be diagnosed by C owner. + +B08 final SHA: Rust fmt / cargo check / tests / clippy / generated-source parity / hosted Windows/macOS / native physical GUI = PENDING or NOT_RUN until independent workflows complete. Do not mark PASS on a queued job or claim hosted Windows Server means Windows 10 with MiKTeX. + +Open QA: verify source edit cursor/focus/undo after typing, native Save/Open with Unicode path, theme Light/Dark/Midnight/Paper at compact/comfortable, precise PDF not aligned with provisional guide, missing arrows.meta workflow, node text math/Unicode, control points, external builds after parameter Apply. Physical screenshots and screenshots at 1366x768 and Retina are NOT_RUN. + +## Next gate + +Review final B08 exact-head CI logs, fix only B app ownership failures, request C fixes for C07 compilation faults, then integrator cherry-pick/merge chain without duplicating B07. Keep app's own plans/insights in this PR. + + +## B08 repository fixture — targeted local XeLaTeX proof + +An exact textual copy of crates/tikzarium-app/fixtures/b08_catalog_six.tex (ASCII/portable, six primitives, Stealth via arrows.meta, math node and an independent user-defined macro) was compiled under the existing external Linux XeLaTeX with -no-shell-escape/-interaction=nonstopmode/-halt-on-error/-file-line-error. **PASS exit 0**, 1 page, pdftoppm raster visually inspected. Input SHA-256: 278c9219a27d4e49bf9178de3d238fe3fdcaf5f6b6c8e78dae53ade7f1fe5eba; PDF SHA-256: ece7edf94a03bfd445427813a59244276d9ab77b016b0c610c004e1154db9883. The tested file is a manually copied source, not a downloaded readback of the GitHub blob. Visual shows the six simple forms overlapping by the intentional literal coordinates. This does NOT establish Rust generator parity, app pointer input, or physical Windows/Mac acceptance. + +GitHub hosted B08 Rust CI on ceab19a9ed96d62e2311da1a3e39b606f4c825c2 still fails at shared C07 dependency: rustfmt cannot parse C primitives.rs line 176 malformed math char literals, and C tests/primitive_edit.rs has formatting diff. The *B* rustfmt reconstruction on ceab19a9 produces **zero B-file differences** (verified from hosted macOS job logs). Comment sent to C PR #39; B does not edit C code. All B compile/tests remain unverified until C fix. diff --git a/plans/2026-10-31_B07_TIKZ_NATIVE_FIRST_VERTICAL.md b/plans/2026-10-31_B07_TIKZ_NATIVE_FIRST_VERTICAL.md new file mode 100644 index 00000000..5470714d --- /dev/null +++ b/plans/2026-10-31_B07_TIKZ_NATIVE_FIRST_VERTICAL.md @@ -0,0 +1,26 @@ +# B07 — first native TikZ source-authoring vertical +Date 2026-10-31. Baseline main c5c8c1576b6288e1d125d558d0d4872b321efb86. B branch feat/b07-native-tikz-primitive-workbench-20261031. + +## Owner priority and preflight +Read actual main SHA, AGENTS, app/core/extension APIs, B06 and C plans/insights and open PRs #25–#28, #31–#34. Prior B #16/#19/#20/#29 already integrated and not duplicated. Owner's new 2026-10-31 native-TikZ direction overrides old CAD/solver forecast in PR #32; #32 has NOT yet been revised. No TeX interpreter or independent geometry/CAS engine is being built. + +## Scope +Permanent Split defaults to source left, exact XeLaTeX PDF plus separately identified approximate guide pad center, compact properties and Russian contextual help right. F1 and hover use one B-side ToolSpec table. Six real source generators: line, arrow, rectangle, circle, text node, cubic Bézier. Click/drag obtains a quarter-grid point (29 px/cm guide only), inserts one line into a single obvious tikzpicture, and selects its source line. Parameter Apply edits only a precisely recognized generated line. Save/reopen can reidentify tagged lines; undo verifies its exact source patch. Arbitrary macro code remains untouched and accessible. + +C Registry supplies PGF/TikZ core installed status and XeLaTeX compatibility; a confirmed explicit usepackage is required. A missing package is NOT installed; when installed-but-not-loaded, user can inspect an explicit usepackage source diff, accept or undo. Single-page or arbitrary transformed PDF is never treated as a coordinate-calibrated canvas. + +## C interface debt +Current C registry supports package metadata but no primitive generators; C04 audit deliberately forbids promoting manifest visual_features. B07 does not change C files or claim C adapter readiness. Via issue #4 requested a versioned C-owned primitive catalogue with syntax/type/unit/default provenance/primary section, package/library and build/load/edit states. The interim B-only table is a **single source** for toolbar/help/inspector and must be replaced once C delivers an accepted contract. + +## Safety/architecture +External XeLaTeX remains source-of-truth renderer (Windows 10 x64 independently installed MiKTeX, Mac ARM external XeLaTeX). B07 no shell execution, core API change, font/theme coupling, universal parser or macro rewriting. Multiple pictures and changed/duplicated marker tags fail closed. Byte-exact source edits only on own inserted command or explicit preamble line. Unknown transformations can shift exact TeX coordinates relative to the crude guide and are not visually editable. Existing source can be viewed/edited as text even without an installed package. + +## QA gates (not claims) +- Unit: six roundtrips, parameter edit and two-step undo, reopen, tampering, multiple-picture ambiguity, package diff and CRLF, unsafe parameter refusal. +- Rust fmt, tests, clippy on exact PR HEAD: PENDING/NOT_RUN until actual CI. +- Real XeLaTeX six-primitive fixture build, log and PDF hashes: NOT_RUN. +- Physical Win10/M1/M4 GUI screenshot, DPI/Retina, dialogs, keyboard/IME: NOT_RUN. +- PR #33 independent document external-edit conflict fix remains integration-owned. Check once merged. + +## Next gate +Fix any exact-head CI failures in B branch without rewriting neighbor files. Then collect actual native screenshots and external XeLaTeX proof. Replace provisional B descriptors when C provides reviewed primitive catalogue/API; avoid recreating generic TikZ/CST semantics. diff --git a/plans/2026-10-31_B08_CATALOG_DRIVEN_AUTHORING.md b/plans/2026-10-31_B08_CATALOG_DRIVEN_AUTHORING.md new file mode 100644 index 00000000..85a4d70c --- /dev/null +++ b/plans/2026-10-31_B08_CATALOG_DRIVEN_AUTHORING.md @@ -0,0 +1,28 @@ +# B08 — C-catalogue-driven TikZ authoring (stacked on C07) + +Date: 2026-10-31. Independent B owner: crates/tikzarium-app. Base: C07 draft PR #39 exact SHA f5e31c54604d567bcfd8c84eb84cbc7c1ad821ac; B07 prototype PR #37 exact SHA 12bdd0840f197d3c46ba8817887fe1a8b6c196c2. The B08 branch intentionally transplants only B07 files over C07 so integration has one reproducible contract. This does not merge C07 or B07 into main. + +## Scope and source authority + +- Use C's Catalog::bundled(), C ToolSpec/ParameterSpec, Catalog::generate and SourcePatch, not an independent B syntax/help table. The six B gestures map to C stable IDs tikz.line, tikz.arrow, tikz.rectangle, tikz.circle, tikz.node and tikz.bezier-cubic. +- The permanent Split mode keeps source, *separately labelled approximate* pointer guide and exact XeLaTeX PDF simultaneously visible, plus a compact C-backed inspector/help (F1 and hover). Only generated B08-marked commands can be reselected for visual editing. Unknown TeX/macros remain source-editable, never normalized. +- Generated coordinates are explicit cm literals. Arrow tips come from C's verified allowed choices and require project \usetikzlibrary{arrows.meta}; node content offers C text escaping and explicit raw TeX math. Cubic curve controls are generated through C from the B pointer bend preset; no independent TeX/geometry engine. +- Installed PGF/TikZ, loaded source preamble, engine compatibility, B visual support, and library loaded are distinct states. Display the explicit \usepackage{tikz} / \usetikzlibrary{arrows.meta} proposed diff and let the user apply/Undo; no MiKTeX global settings change or package installation. +- Application records source fingerprint and source revision, applies minimal UTF-8-safe C SourcePatch, refuses unexpected changes and invalidates transient undo on direct Source edits. Code from unfamiliar transformations is not reverse mapped. C-owned parameters keep editor preset provenance (not PGF defaults). + +## Integration limits + +This is a **stacked draft on C07**, not safe to merge independently before the C Rust generator passes CI. B07 #37 is superseded only after an integrator checks B08's source parity and exact-head CI. A owns MiKTeX/XeLaTeX runner and no core API was modified. D owns independent physical-host QA. + +## Acceptance — code vs evidence + +1. For all six tools: mouse gesture/pointer draft -> C generator -> immediate selected source command -> parameter inspector -> source-range patch/Undo -> save/reopen, without changed unrelated macros/comments. +2. For arrow: installed package alone does not imply project-loaded arrows.meta; offer a visible reversible preamble change before enabling insertion. Text/math node parameter controls and help are C-owned. +3. F1/hover/inspector obtain all six tool descriptions, 28 parameter explanations/default provenance/units/sections from one catalogue. +4. Actual local Linux XeLaTeX compilation of a **manually assembled matching syntax fixture** passed (2026-10-31), but generated-output byte parity, GUI interaction and physical desktop acceptance are NOT_RUN. Refer to B08 insights. +5. Rust formatting/compile/test/Clippy and CI on the **final B08 SHA** are required before merge; successful CI on C, B07, or a different SHA does not establish B08 PASS. +6. Physical Windows 10 x64 with separately installed MiKTeX and physical macOS ARM64 M1/M4 are separate NOT_RUN gates. Hosted Windows Server/macOS builds do not replace screenshot, DPI/Retina/IME/dialog checks. + +## Blocking/recovery + +C07 must be exact-head CI green; C source currently has suspicious malformed math escaping in the fetched primitive generator. B does not patch C-owned code. If B08 CI diagnoses C failure, report to C PR #39 with exact lines instead of hiding it; do not alter C code in this B branch. Preserve original B07 until the stacked approach is independently accepted.