From 64592853f58c004aa1d6b53babf652b7f7160a44 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 12:35:07 +0300 Subject: [PATCH 1/9] feat(B08): stage B07 UI on C07 catalog foundation (integration base) --- crates/tikzarium-app/QA_B07_NATIVE_DRAWING.md | 17 + .../tikzarium-app/fixtures/b07_native_six.tex | 15 + crates/tikzarium-app/src/main.rs | 115 ++- crates/tikzarium-app/src/tikz_authoring.rs | 680 ++++++++++++++++++ crates/tikzarium-app/src/tikz_canvas.rs | 412 +++++++++++ .../2026-10-31_B07_TIKZ_NATIVE_VERTICAL_QA.md | 26 + ...26-10-31_B07_TIKZ_NATIVE_FIRST_VERTICAL.md | 26 + 7 files changed, 1264 insertions(+), 27 deletions(-) create mode 100644 crates/tikzarium-app/QA_B07_NATIVE_DRAWING.md create mode 100644 crates/tikzarium-app/fixtures/b07_native_six.tex create mode 100644 crates/tikzarium-app/src/tikz_authoring.rs create mode 100644 crates/tikzarium-app/src/tikz_canvas.rs create mode 100644 insights/2026-10-31_B07_TIKZ_NATIVE_VERTICAL_QA.md create mode 100644 plans/2026-10-31_B07_TIKZ_NATIVE_FIRST_VERTICAL.md 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/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/src/main.rs b/crates/tikzarium-app/src/main.rs index 5a076c1d..8ea288d0 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; @@ -514,10 +526,24 @@ impl TikzariumApp { 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 +563,44 @@ 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, // Only B07's six explicitly narrow built-in primitives. + } + } + + 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 +661,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 B07-owned 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 +864,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 +971,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 +982,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 +1042,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..6aff5922 --- /dev/null +++ b/crates/tikzarium-app/src/tikz_authoring.rs @@ -0,0 +1,680 @@ +//! 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::ops::Range; + +pub const MARKER: &str = " % tikzarium:b07:"; +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", + } + } + fn from_id(id: &str) -> Option { + TOOLS.into_iter().find(|tool| tool.id() == id) + } +} + +/// Transitional B-side view until C publishes a versioned primitive schema. +/// One entry drives toolbar, inspector, hints and help; never infer from a +/// package being installed that an arbitrary TikZ expression is editable. +pub struct ToolSpec { + pub tool: Tool, + pub title: &'static str, + pub explanation: &'static str, + pub syntax: &'static str, + pub parameters: &'static str, + pub defaults: &'static str, + pub effect: &'static str, + pub example: &'static str, + pub source: &'static str, +} + +pub const SPECS: [ToolSpec; 6] = [ + ToolSpec { + tool: Tool::Line, + title: "Линия", + explanation: "Прямой отрезок TikZ: путь из двух декартовых координат.", + syntax: r"\draw (x1,y1) -- (x2,y2);", + parameters: "x1, y1, x2, y2: decimal, сантиметры в примере; координаты TikZ без явной единицы используют текущие x/y-векторы.", + defaults: "TikZ defaults зависят от picture/options — UNKNOWN; B07 задаёт только новые координаты.", + effect: "Создаёт stroke без наконечника.", + example: r"\draw (0,0) -- (2,1);", + source: "PGF/TikZ Manual §14.2.1 Straight Lines: https://tikz.dev/tikz-paths", + }, + ToolSpec { + tool: Tool::Arrow, + title: "Стрелка", + explanation: "Прямая линия с наконечником, выбранным стилем TikZ.", + syntax: r"\draw[->] (x1,y1) -- (x2,y2);", + parameters: "x1, y1, x2, y2: decimal, базовые единицы координат; ->: arrow-tip shorthand.", + defaults: "Конкретный тип наконечника зависит от текущих стилей — UNKNOWN.", + effect: "Рисует путь с наконечником в конце.", + example: r"\draw[->] (0,0) -- (2,1);", + source: "PGF/TikZ Manual §16.2 Arrow Tips: https://tikz.dev/tikz-arrows", + }, + ToolSpec { + tool: Tool::Rectangle, + title: "Прямоугольник", + explanation: "TikZ rectangle между противоположными углами.", + syntax: r"\draw (x1,y1) rectangle (x2,y2);", + parameters: "x1, y1, x2, y2: decimal, относительные координаты выбранной picture.", + defaults: "Толщина/цвет линий наследуются от стилей — UNKNOWN.", + effect: "Замкнутый прямоугольный путь.", + example: r"\draw (0,0) rectangle (2,1);", + source: "PGF/TikZ Manual §14.4 Rectangle Operation: https://tikz.dev/tikz-paths", + }, + ToolSpec { + tool: Tool::Circle, + title: "Окружность", + explanation: "Окружность TikZ вокруг центра с явным радиусом.", + syntax: r"\draw (x,y) circle[radius=r cm];", + parameters: "x,y: координаты; radius: положительное число с единицей cm (B07).", + defaults: "B07: radius=1cm при одиночном клике; это НЕ документированный default TikZ.", + effect: "Рисует окружность, без заливки.", + example: r"\draw (1,1) circle[radius=1cm];", + source: "PGF/TikZ Manual §14.6 Circle and Ellipse: https://tikz.dev/tikz-paths", + }, + ToolSpec { + tool: Tool::Node, + title: "Узел", + explanation: "Текстовый узел \node с опорной точкой at.", + syntax: r"\node at (x,y) {text};", + parameters: "x,y: координаты; text: простой Unicode-текст без TeX-спецсимволов в B07.", + defaults: "B07: text=Text; якорь/шрифт/внутренние отступы наследуются из TeX — UNKNOWN.", + effect: "Создаёт текстовый TikZ node.", + example: r"\node at (1,1) {Text};", + source: "PGF/TikZ Manual §17.2.1 Node Syntax: https://tikz.dev/tikz-shapes", + }, + ToolSpec { + tool: Tool::Cubic, + title: "Кривая Bézier", + explanation: "Кубический путь TikZ с двумя контрольными точками.", + syntax: r"\draw (x1,y1) .. controls (c1x,c1y) and (c2x,c2y) .. (x2,y2);", + parameters: "Четыре пары координат; B07 bend (cm) лишь задаёт обе контрольные точки по шаблону.", + defaults: "B07 bend=0.8cm (локальная настройка генератора, не default TikZ).", + effect: "Кубическая кривая между двумя точками; контрольные точки явно записаны в источнике.", + example: r"\draw (0,0) .. controls (1,1) and (2,1) .. (3,0);", + source: "PGF/TikZ Manual §14.3 Cubic Curve-To: https://tikz.dev/tikz-paths", + }, +]; + +pub fn spec(tool: Tool) -> &'static ToolSpec { + SPECS + .iter() + .find(|item| item.tool == tool) + .expect("built-in tool") +} + +#[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, +} +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(), + } + } +} + +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(|c| c.is_control() || "#$%&_{}~^\\".contains(c)) +} +pub fn generate(tool: Tool, draft: &Draft, id: u64) -> Result { + 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 text must be plain Unicode (1–80 chars; TeX special characters require source editing)".into()); + } + let a = draft.start; + let b = draft.end; + let source = match tool { + Tool::Line => format!( + r" \draw ({:.2},{:.2}) -- ({:.2},{:.2});", + a.x, a.y, b.x, b.y + ), + Tool::Arrow => format!( + r" \draw[->] ({:.2},{:.2}) -- ({:.2},{:.2});", + a.x, a.y, b.x, b.y + ), + Tool::Rectangle => format!( + r" \draw ({:.2},{:.2}) rectangle ({:.2},{:.2});", + a.x, a.y, b.x, b.y + ), + Tool::Circle => { + let radius = ((b.x - a.x).hypot(b.y - a.y)).max(0.1); + format!( + r" \draw ({:.2},{:.2}) circle[radius={:.2}cm];", + a.x, a.y, radius + ) + } + Tool::Node => format!(r" \node at ({:.2},{:.2}) {{{}}};", a.x, a.y, draft.text), + Tool::Cubic => { + let dx = (b.x - a.x) / 3.0; + format!( + r" \draw ({:.2},{:.2}) .. controls ({:.2},{:.2}) and ({:.2},{:.2}) .. ({:.2},{:.2});", + a.x, + a.y, + a.x + dx, + a.y + draft.bend, + b.x - dx, + b.y + draft.bend, + b.x, + b.y + ) + } + }; + Ok(format!("{source}{MARKER}{id}:{}", tool.id())) +} + +fn read_point(raw: &str) -> Option { + let (x, y) = raw.split_once(',')?; + let point = Point::new(x.parse().ok()?, y.parse().ok()?); + (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. +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)?; + let coords = points(command)?; + 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]; + } + 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(" {").nth(1)?.strip_suffix("};")?; + draft.start = coords[0]; + draft.text = text.into(); + } + 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 +} + +pub fn tikz_loaded(source: &str) -> bool { + source.lines().map(unescaped_comment).any(|line| { + let compact: String = line.chars().filter(|c| !c.is_whitespace()).collect(); + [r"\usepackage{tikz}", r"\RequirePackage{tikz}"] + .iter() + .any(|token| compact.starts_with(token)) + }) +} + +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" + } +} + +#[derive(Clone, Debug)] +struct Patch { + before: String, + after: String, +} + +#[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, + 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.next_id = owned(source) + .iter() + .map(|item| item.id) + .max() + .unwrap_or(0) + .saturating_add(1) + .max(1); + self.status = "Source-first: arbitrary macros remain untouched".into(); + } + + pub fn undo_available(&self) -> bool { + !self.undo.is_empty() + } + + pub fn choose(&mut self, source: &str, id: u64) -> Result { + 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 { + if !tikz_loaded(source) { + return Err( + "TikZ not explicitly loaded; use the proposed \\usepackage diff first".into(), + ); + } + 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; + source.insert_str(at, &after); + self.undo.push(Patch { + before: String::new(), + after, + }); + self.next_id = id.saturating_add(1); + self.active = Some(tool); + self.selected = Some(id); + self.draft = draft; + self.status = format!( + "Inserted native TikZ {} · Build XeLaTeX to update exact PDF", + tool.id() + ); + Ok(number) + } + + pub fn apply_parameters(&mut self, source: &mut String) -> Result { + let id = self.selected.ok_or("Select one B07-owned command first")?; + let old = one_owned(source, id)?; + let after = generate(old.tool, &self.draft, id)?; + if old.text != after { + source.replace_range(old.range, &after); + self.undo.push(Patch { + before: old.text, + after, + }); + } + self.status = + "Changed only selected B07-owned source line; exact 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::propose_usepackage(source)?; + let at = unique_line_start(source, r"\begin{document}", true)?; + let after = format!("\\usepackage{{tikz}}{}", eol(source)); + source.insert_str(at, &after); + self.undo.push(Patch { + before: String::new(), + after, + }); + self.status = "Added \\usepackage{tikz}; Undo visual restores it. Build to verify.".into(); + Ok(()) + } + + pub fn undo(&mut self, source: &mut String) -> Result<(), String> { + let patch = self.undo.last().ok_or("No visual patch to undo")?.clone(); + let mut hits = source.match_indices(&patch.after); + let Some((at, _)) = hits.next() else { + return Err("Undo refused: owned source was edited since patch".into()); + }; + if hits.next().is_some() { + return Err("Undo refused: ambiguous source match".into()); + } + source.replace_range(at..at + patch.after.len(), &patch.before); + self.undo.pop(); + 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 only the exact B07 source patch".into(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + const SOURCE: &str = "\\documentclass{standalone}\n\\usepackage{tikz}\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, 7); + 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.25,2.00)")); + 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.00,1.00)", r"\draw[red] (1.00,1.00)"); + 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 unsafe_node_text_and_invalid_numbers_are_refused() { + let mut draft = Draft { + text: r"a_b".to_owned(), + ..Default::default() + }; + assert!(generate(Tool::Node, &draft, 1).is_err()); + draft.text = "Привет".into(); + assert!(generate(Tool::Node, &draft, 1).is_ok()); + draft.start.x = f32::NAN; + assert!(generate(Tool::Line, &draft, 1).is_err()); + } +} diff --git a/crates/tikzarium-app/src/tikz_canvas.rs b/crates/tikzarium-app/src/tikz_canvas.rs new file mode 100644 index 00000000..4004f394 --- /dev/null +++ b/crates/tikzarium-app/src/tikz_canvas.rs @@ -0,0 +1,412 @@ +//! 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, +} + +impl Capabilities { + pub fn can_draw(self) -> bool { + self.installed && self.loaded && self.engine_compatible && self.implemented + } + 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(), + egui::Button::new(info.title).selected(session.active == Some(tool)), + ) + .on_hover_text(format!( + "{}\n{}\n{}", + info.explanation, info.syntax, info.source + )); + 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 !caps.can_draw() || session.active.is_none() { + 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("Только собственные B07-команды. Любые макросы остаются в Source."); + ui.small(format!( + "PGF/TikZ: installed={} · loaded={} · XeLaTeX-compatible={} · B-implemented={}", + caps.installed, caps.loaded, caps.engine_compatible, caps.implemented, + )); + ui.label(caps.explanation()); + ui.separator(); + + let choices = tikz_authoring::owned(source); + egui::ComboBox::from_id_salt("tikz-b07-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, + 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); + 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::Node { + ui.label("Node text (plain Unicode):"); + 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!("Source-owned marker: B07 #{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("Контекстная справка"); + ui.label(info.explanation); + ui.small(format!("Синтаксис: {}", info.syntax)); + ui.small(format!("Параметры: {}", info.parameters)); + ui.small(format!("Default: {}", info.defaults)); + ui.small(format!("Эффект: {}", info.effect)); + ui.code(info.example); + ui.small("Зависимость: \\usepackage{tikz}; library: core (дополнительная не нужна)."); + ui.small(format!("Первичный источник: {}", info.source)); + ui.small("Состояния: installed / loaded / XeLaTeX-compatible / B adapter are separate; compiled state NOT_VERIFIED."); + } + } 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 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/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. From f5402615ce15e9976285319d72c1bbcbad848693 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 12:36:07 +0300 Subject: [PATCH 2/9] WIP(B08): adapt B authored commands to C primitive catalogue --- crates/tikzarium-app/src/tikz_authoring.rs | 257 ++++++++++----------- 1 file changed, 116 insertions(+), 141 deletions(-) diff --git a/crates/tikzarium-app/src/tikz_authoring.rs b/crates/tikzarium-app/src/tikz_authoring.rs index 6aff5922..86419e96 100644 --- a/crates/tikzarium-app/src/tikz_authoring.rs +++ b/crates/tikzarium-app/src/tikz_authoring.rs @@ -2,9 +2,15 @@ //! 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::ops::Range; - -pub const MARKER: &str = " % tikzarium:b07:"; +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, @@ -35,100 +41,29 @@ impl Tool { 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) } } -/// Transitional B-side view until C publishes a versioned primitive schema. -/// One entry drives toolbar, inspector, hints and help; never infer from a -/// package being installed that an arbitrary TikZ expression is editable. -pub struct ToolSpec { - pub tool: Tool, - pub title: &'static str, - pub explanation: &'static str, - pub syntax: &'static str, - pub parameters: &'static str, - pub defaults: &'static str, - pub effect: &'static str, - pub example: &'static str, - pub source: &'static str, +/// 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 const SPECS: [ToolSpec; 6] = [ - ToolSpec { - tool: Tool::Line, - title: "Линия", - explanation: "Прямой отрезок TikZ: путь из двух декартовых координат.", - syntax: r"\draw (x1,y1) -- (x2,y2);", - parameters: "x1, y1, x2, y2: decimal, сантиметры в примере; координаты TikZ без явной единицы используют текущие x/y-векторы.", - defaults: "TikZ defaults зависят от picture/options — UNKNOWN; B07 задаёт только новые координаты.", - effect: "Создаёт stroke без наконечника.", - example: r"\draw (0,0) -- (2,1);", - source: "PGF/TikZ Manual §14.2.1 Straight Lines: https://tikz.dev/tikz-paths", - }, - ToolSpec { - tool: Tool::Arrow, - title: "Стрелка", - explanation: "Прямая линия с наконечником, выбранным стилем TikZ.", - syntax: r"\draw[->] (x1,y1) -- (x2,y2);", - parameters: "x1, y1, x2, y2: decimal, базовые единицы координат; ->: arrow-tip shorthand.", - defaults: "Конкретный тип наконечника зависит от текущих стилей — UNKNOWN.", - effect: "Рисует путь с наконечником в конце.", - example: r"\draw[->] (0,0) -- (2,1);", - source: "PGF/TikZ Manual §16.2 Arrow Tips: https://tikz.dev/tikz-arrows", - }, - ToolSpec { - tool: Tool::Rectangle, - title: "Прямоугольник", - explanation: "TikZ rectangle между противоположными углами.", - syntax: r"\draw (x1,y1) rectangle (x2,y2);", - parameters: "x1, y1, x2, y2: decimal, относительные координаты выбранной picture.", - defaults: "Толщина/цвет линий наследуются от стилей — UNKNOWN.", - effect: "Замкнутый прямоугольный путь.", - example: r"\draw (0,0) rectangle (2,1);", - source: "PGF/TikZ Manual §14.4 Rectangle Operation: https://tikz.dev/tikz-paths", - }, - ToolSpec { - tool: Tool::Circle, - title: "Окружность", - explanation: "Окружность TikZ вокруг центра с явным радиусом.", - syntax: r"\draw (x,y) circle[radius=r cm];", - parameters: "x,y: координаты; radius: положительное число с единицей cm (B07).", - defaults: "B07: radius=1cm при одиночном клике; это НЕ документированный default TikZ.", - effect: "Рисует окружность, без заливки.", - example: r"\draw (1,1) circle[radius=1cm];", - source: "PGF/TikZ Manual §14.6 Circle and Ellipse: https://tikz.dev/tikz-paths", - }, - ToolSpec { - tool: Tool::Node, - title: "Узел", - explanation: "Текстовый узел \node с опорной точкой at.", - syntax: r"\node at (x,y) {text};", - parameters: "x,y: координаты; text: простой Unicode-текст без TeX-спецсимволов в B07.", - defaults: "B07: text=Text; якорь/шрифт/внутренние отступы наследуются из TeX — UNKNOWN.", - effect: "Создаёт текстовый TikZ node.", - example: r"\node at (1,1) {Text};", - source: "PGF/TikZ Manual §17.2.1 Node Syntax: https://tikz.dev/tikz-shapes", - }, - ToolSpec { - tool: Tool::Cubic, - title: "Кривая Bézier", - explanation: "Кубический путь TikZ с двумя контрольными точками.", - syntax: r"\draw (x1,y1) .. controls (c1x,c1y) and (c2x,c2y) .. (x2,y2);", - parameters: "Четыре пары координат; B07 bend (cm) лишь задаёт обе контрольные точки по шаблону.", - defaults: "B07 bend=0.8cm (локальная настройка генератора, не default TikZ).", - effect: "Кубическая кривая между двумя точками; контрольные точки явно записаны в источнике.", - example: r"\draw (0,0) .. controls (1,1) and (2,1) .. (3,0);", - source: "PGF/TikZ Manual §14.3 Cubic Curve-To: https://tikz.dev/tikz-paths", - }, -]; - -pub fn spec(tool: Tool) -> &'static ToolSpec { - SPECS - .iter() - .find(|item| item.tool == tool) - .expect("built-in tool") +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)] @@ -148,6 +83,8 @@ pub struct Draft { pub end: Point, pub bend: f32, pub text: String, + pub tip: String, + pub mode: String, } impl Default for Draft { fn default() -> Self { @@ -156,6 +93,8 @@ impl Default for Draft { end: Point::new(3.0, 2.0), bend: 0.8, text: "Text".into(), + tip: "Stealth".into(), + mode: "text".into(), } } } @@ -164,19 +103,15 @@ 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(|c| c.is_control() || "#$%&_{}~^\\".contains(c)) + !text.is_empty() && text.chars().count() <= 80 && !text.chars().any(char::is_control) } -pub fn generate(tool: Tool, draft: &Draft, id: u64) -> Result { +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, + draft.start.x, draft.start.y, draft.end.x, draft.end.y, draft.bend, ] .into_iter() .all(safe_number) @@ -184,52 +119,60 @@ pub fn generate(tool: Tool, draft: &Draft, id: u64) -> Result { return Err("Coordinates must be finite and within ±1000".into()); } if tool == Tool::Node && !safe_text(&draft.text) { - return Err("Node text must be plain Unicode (1–80 chars; TeX special characters require source editing)".into()); + 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; - let source = match tool { - Tool::Line => format!( - r" \draw ({:.2},{:.2}) -- ({:.2},{:.2});", - a.x, a.y, b.x, b.y - ), - Tool::Arrow => format!( - r" \draw[->] ({:.2},{:.2}) -- ({:.2},{:.2});", - a.x, a.y, b.x, b.y - ), - Tool::Rectangle => format!( - r" \draw ({:.2},{:.2}) rectangle ({:.2},{:.2});", - a.x, a.y, b.x, b.y - ), + 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 => { - let radius = ((b.x - a.x).hypot(b.y - a.y)).max(0.1); - format!( - r" \draw ({:.2},{:.2}) circle[radius={:.2}cm];", - a.x, a.y, radius - ) + 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 => format!(r" \node at ({:.2},{:.2}) {{{}}};", a.x, a.y, draft.text), - Tool::Cubic => { - let dx = (b.x - a.x) / 3.0; - format!( - r" \draw ({:.2},{:.2}) .. controls ({:.2},{:.2}) and ({:.2},{:.2}) .. ({:.2},{:.2});", - a.x, - a.y, - a.x + dx, - a.y + draft.bend, - b.x - dx, - b.y + draft.bend, - b.x, - b.y - ) + 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()); } - }; - Ok(format!("{source}{MARKER}{id}:{}", tool.id())) + } + 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 point = Point::new(x.parse().ok()?, y.parse().ok()?); + 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) } @@ -257,17 +200,43 @@ pub struct Owned { /// 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)?; - let coords = points(command)?; + // 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 @@ -280,9 +249,15 @@ fn parse_line(line: &str, start: usize, number: usize) -> Option { draft.end = Point::new(coords[0].x + radius, coords[0].y); } Tool::Node if coords.len() == 1 => { - let text = command.split(" {").nth(1)?.strip_suffix("};")?; + let text = command.split_once(" {")?.1.strip_suffix("};")?; draft.start = coords[0]; - draft.text = text.into(); + 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]; From 5ff064cfb02f4aa9e71d3307d633d90cd446fd0a Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 12:37:26 +0300 Subject: [PATCH 3/9] WIP(B08): C primitive source patches, library-aware undo and parameter edits --- crates/tikzarium-app/src/tikz_authoring.rs | 210 ++++++++++++++------- 1 file changed, 145 insertions(+), 65 deletions(-) diff --git a/crates/tikzarium-app/src/tikz_authoring.rs b/crates/tikzarium-app/src/tikz_authoring.rs index 86419e96..298ab59b 100644 --- a/crates/tikzarium-app/src/tikz_authoring.rs +++ b/crates/tikzarium-app/src/tikz_authoring.rs @@ -321,14 +321,31 @@ fn unescaped_comment(line: &str) -> &str { line } -pub fn tikz_loaded(source: &str) -> bool { +/// 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(); - [r"\usepackage{tikz}", r"\RequirePackage{tikz}"] - .iter() - .any(|token| compact.starts_with(token)) + 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; @@ -370,10 +387,44 @@ fn eol(source: &str) -> &'static str { } } +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 { - before: String, - after: String, + 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)] @@ -385,9 +436,10 @@ pub struct Session { 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; @@ -395,38 +447,58 @@ impl Session { self.draft = Draft::default(); self.drag_start = None; self.undo.clear(); - self.next_id = owned(source) - .iter() - .map(|item| item.id) - .max() - .unwrap_or(0) - .saturating_add(1) - .max(1); - self.status = "Source-first: arbitrary macros remain untouched".into(); + 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 { + 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; use the proposed \\usepackage diff first".into(), - ); + 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); @@ -436,70 +508,78 @@ impl Session { let line = generate(tool, &draft, id)?; let after = format!("{}{}", line, eol(source)); let number = source[..at].bytes().filter(|b| *b == b'\n').count() + 1; - source.insert_str(at, &after); - self.undo.push(Patch { - before: String::new(), - after, - }); + 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!( - "Inserted native TikZ {} · Build XeLaTeX to update exact PDF", - tool.id() - ); + 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 { - let id = self.selected.ok_or("Select one B07-owned command first")?; + 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 { - source.replace_range(old.range, &after); - self.undo.push(Patch { - before: old.text, - after, - }); + let patch = minimal_changed_span(&old.text, &after, old.range.start, self.revision)?; + self.apply_patch(source, patch)?; } - self.status = - "Changed only selected B07-owned source line; exact PDF requires XeLaTeX Build".into(); + 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()); - } + 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)); - source.insert_str(at, &after); - self.undo.push(Patch { - before: String::new(), - after, - }); - self.status = "Added \\usepackage{tikz}; Undo visual restores it. Build to verify.".into(); + 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(); - let mut hits = source.match_indices(&patch.after); - let Some((at, _)) = hits.next() else { - return Err("Undo refused: owned source was edited since patch".into()); - }; - if hits.next().is_some() { - return Err("Undo refused: ambiguous source match".into()); + if source_fingerprint(source) != patch.after_fingerprint { + return Err("Undo refused: source edited since visual patch".into()); } - source.replace_range(at..at + patch.after.len(), &patch.before); + 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); @@ -509,7 +589,7 @@ impl Session { self.draft = Draft::default(); } } - self.status = "Undo: reverted only the exact B07 source patch".into(); + self.status = "Undo: reverted exact C-validated source range".into(); Ok(()) } } From 3096f0d2d275fd40943bae868ab4796e17ec8913 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 12:38:02 +0300 Subject: [PATCH 4/9] WIP(B08): exercise C-driven six primitives, library state and Unicode undo --- crates/tikzarium-app/src/tikz_authoring.rs | 105 +++++++++++++++++++-- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/crates/tikzarium-app/src/tikz_authoring.rs b/crates/tikzarium-app/src/tikz_authoring.rs index 298ab59b..ac8d7619 100644 --- a/crates/tikzarium-app/src/tikz_authoring.rs +++ b/crates/tikzarium-app/src/tikz_authoring.rs @@ -597,7 +597,7 @@ impl Session { #[cfg(test)] mod tests { use super::*; - const SOURCE: &str = "\\documentclass{standalone}\n\\usepackage{tikz}\n\\begin{document}\n\\begin{tikzpicture}\n% user opaque code\n\\myMacro{x}\n\\end{tikzpicture}\n\\end{document}\n"; + 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() { @@ -606,7 +606,7 @@ mod tests { let mut session = Session::default(); session.reset(&src); let line = session.insert(&mut src, tool, Draft::default()).unwrap(); - assert_eq!(line, 7); + 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())); @@ -626,7 +626,7 @@ mod tests { let inserted = src.clone(); session.draft.end.x = 5.25; session.apply_parameters(&mut src).unwrap(); - assert!(src.contains("rectangle (5.25,2.00)")); + assert!(src.contains("rectangle (5.25cm,2.00cm)")); assert!(src.contains("\\myMacro{x}")); session.undo(&mut src).unwrap(); assert_eq!(src, inserted); @@ -660,7 +660,7 @@ mod tests { session .insert(&mut src, Tool::Line, Draft::default()) .unwrap(); - src = src.replace(r"\draw (1.00,1.00)", r"\draw[red] (1.00,1.00)"); + 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(); @@ -721,15 +721,108 @@ mod tests { } #[test] - fn unsafe_node_text_and_invalid_numbers_are_refused() { + 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).is_err()); + 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); + } + } From 5ed54232581df0da5eb5108ec3c91beaf81e2035 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 12:38:49 +0300 Subject: [PATCH 5/9] WIP(B08): bind primitive toolbar to C catalogue and per-tool library state --- crates/tikzarium-app/src/tikz_canvas.rs | 43 +++++++++++++++++-------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/crates/tikzarium-app/src/tikz_canvas.rs b/crates/tikzarium-app/src/tikz_canvas.rs index 4004f394..39f79ee3 100644 --- a/crates/tikzarium-app/src/tikz_canvas.rs +++ b/crates/tikzarium-app/src/tikz_canvas.rs @@ -10,12 +10,23 @@ pub struct Capabilities { 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)." @@ -132,12 +143,12 @@ pub fn show_guide( let info = tikz_authoring::spec(tool); let response = ui .add_enabled( - caps.can_draw(), - egui::Button::new(info.title).selected(session.active == Some(tool)), + caps.can_draw_tool(tool), + egui::Button::new(&info.title_ru).selected(session.active == Some(tool)), ) .on_hover_text(format!( "{}\n{}\n{}", - info.explanation, info.syntax, info.source + info.help_ru, info.syntax, info.manual_section )); if response.clicked() { session.active = Some(tool); @@ -207,7 +218,13 @@ pub fn show_guide( } } - if !caps.can_draw() || session.active.is_none() { + 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() @@ -257,10 +274,10 @@ pub fn show_inspector( ) -> Option { let mut jump = None; ui.heading("TikZ · свойства"); - ui.small("Только собственные B07-команды. Любые макросы остаются в Source."); + ui.small("Только собственные B08-команды каталога C. Чужой TeX остаётся в Source."); ui.small(format!( - "PGF/TikZ: installed={} · loaded={} · XeLaTeX-compatible={} · B-implemented={}", - caps.installed, caps.loaded, caps.engine_compatible, caps.implemented, + "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(); @@ -276,7 +293,7 @@ pub fn show_inspector( let label = format!( "#{} {} · line {}", item.id, - tikz_authoring::spec(item.tool).title, + tikz_authoring::spec(item.tool).title_ru, item.line ); if ui @@ -294,7 +311,7 @@ pub fn show_inspector( if let Some(tool) = session.active { let info = tikz_authoring::spec(tool); ui.separator(); - ui.strong(info.title); + ui.strong(&info.title_ru); ui.label("Точки (условные TikZ x/y, cm для обычной picture):"); ui.horizontal(|ui| { ui.label("x1"); @@ -312,10 +329,8 @@ pub fn show_inspector( .changed() { let radius = radius.clamp(0.1, 1000.0); - session.draft.end = Point::new( - session.draft.start.x + radius, - session.draft.start.y, - ); + session.draft.end = + Point::new(session.draft.start.x + radius, session.draft.start.y); } }); } else if tool != Tool::Node { @@ -344,7 +359,7 @@ pub fn show_inspector( Err(error) => session.status = error, } } - ui.small(format!("Source-owned marker: B07 #{id}")); + ui.small(format!("C-catalogue source marker: B08 #{id}")); } if let Ok(command) = tikz_authoring::generate(tool, &session.draft, session.selected.unwrap_or(0)) From 15b1ade3a1a039942c9cb00c401126f6a7cc71e7 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 12:40:58 +0300 Subject: [PATCH 6/9] feat(B08): wire egui controls, F1 help, and library status to C primitive catalogue --- crates/tikzarium-app/src/main.rs | 11 +++- crates/tikzarium-app/src/tikz_canvas.rs | 74 +++++++++++++++++++++---- 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/crates/tikzarium-app/src/main.rs b/crates/tikzarium-app/src/main.rs index 8ea288d0..125c14f7 100644 --- a/crates/tikzarium-app/src/main.rs +++ b/crates/tikzarium-app/src/main.rs @@ -523,6 +523,9 @@ 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}; @@ -575,7 +578,11 @@ impl TikzariumApp { && core.is_some_and(|status| { status.compilation != tikzarium_extensions::CompilationState::IncompatibleEngine }), - implemented: true, // Only B07's six explicitly narrow built-in primitives. + implemented: true, // B handles scoped interaction; C owns syntax and generation. + arrow_library_loaded: tikz_authoring::library_loaded( + &self.document.source, + "arrows.meta", + ), } } @@ -661,7 +668,7 @@ impl TikzariumApp { } ui.label(&self.preview_status); ui.small(&self.page_navigation_status); - ui.small("Only the guide pad below makes B07-owned source patches. This exact PDF is read-only and is NOT coordinate-aligned with the guide."); + 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) diff --git a/crates/tikzarium-app/src/tikz_canvas.rs b/crates/tikzarium-app/src/tikz_canvas.rs index 39f79ee3..64e2f8c0 100644 --- a/crates/tikzarium-app/src/tikz_canvas.rs +++ b/crates/tikzarium-app/src/tikz_canvas.rs @@ -283,7 +283,7 @@ pub fn show_inspector( ui.separator(); let choices = tikz_authoring::owned(source); - egui::ComboBox::from_id_salt("tikz-b07-owned") + egui::ComboBox::from_id_salt("tikz-b08-owned") .selected_text(match session.selected { Some(id) => format!("Primitive #{id}"), None => "Выберите примитив".into(), @@ -348,8 +348,30 @@ pub fn show_inspector( 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 { - ui.label("Node text (plain Unicode):"); + 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 { @@ -379,16 +401,31 @@ pub fn show_inspector( } if session.help { ui.separator(); - ui.strong("Контекстная справка"); - ui.label(info.explanation); + ui.strong("Контекстная справка · каталог C"); + ui.label(&info.help_ru); ui.small(format!("Синтаксис: {}", info.syntax)); - ui.small(format!("Параметры: {}", info.parameters)); - ui.small(format!("Default: {}", info.defaults)); - ui.small(format!("Эффект: {}", info.effect)); - ui.code(info.example); - ui.small("Зависимость: \\usepackage{tikz}; library: core (дополнительная не нужна)."); - ui.small(format!("Первичный источник: {}", info.source)); - ui.small("Состояния: installed / loaded / XeLaTeX-compatible / B adapter are separate; compiled state NOT_VERIFIED."); + 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 показывает справку для выбранной команды."); @@ -411,6 +448,21 @@ pub fn show_inspector( } } } + 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(), From f964f275b830fefb79174ce55da61d5039b3a10f Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 12:42:36 +0300 Subject: [PATCH 7/9] feat(B08): C catalog-driven TikZ UI, parameter help, preamble diffs, exact source undo, QA --- crates/tikzarium-app/QA_B08_LIBRARY_HELP.md | 27 +++++++++++++++++ .../fixtures/b08_catalog_six.tex | 17 +++++++++++ .../2026-10-31_B08_CATALOG_INTEGRATION_QA.md | 30 +++++++++++++++++++ ...2026-10-31_B08_CATALOG_DRIVEN_AUTHORING.md | 28 +++++++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 crates/tikzarium-app/QA_B08_LIBRARY_HELP.md create mode 100644 crates/tikzarium-app/fixtures/b08_catalog_six.tex create mode 100644 insights/2026-10-31_B08_CATALOG_INTEGRATION_QA.md create mode 100644 plans/2026-10-31_B08_CATALOG_DRIVEN_AUTHORING.md 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..6b9d2466 --- /dev/null +++ b/crates/tikzarium-app/QA_B08_LIBRARY_HELP.md @@ -0,0 +1,27 @@ +# 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. 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/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..93dc1888 --- /dev/null +++ b/insights/2026-10-31_B08_CATALOG_INTEGRATION_QA.md @@ -0,0 +1,30 @@ +# 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. 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. From ceab19a9ed96d62e2311da1a3e39b606f4c825c2 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 12:44:42 +0300 Subject: [PATCH 8/9] style(B08): apply exact rustfmt 1.91 deltas from hosted CI to B app --- crates/tikzarium-app/src/tikz_authoring.rs | 200 +++++++++++++++------ crates/tikzarium-app/src/tikz_canvas.rs | 24 ++- 2 files changed, 169 insertions(+), 55 deletions(-) diff --git a/crates/tikzarium-app/src/tikz_authoring.rs b/crates/tikzarium-app/src/tikz_authoring.rs index ac8d7619..35ed3e86 100644 --- a/crates/tikzarium-app/src/tikz_authoring.rs +++ b/crates/tikzarium-app/src/tikz_authoring.rs @@ -63,7 +63,9 @@ pub fn catalog() -> &'static Catalog { 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") + catalog() + .tool(tool.catalog_id()) + .expect("six stable C tool IDs") } #[derive(Clone, Copy, Debug, PartialEq)] @@ -111,7 +113,11 @@ fn literal_cm(value: f32) -> String { /// 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, + draft.start.x, + draft.start.y, + draft.end.x, + draft.end.y, + draft.bend, ] .into_iter() .all(safe_number) @@ -119,7 +125,9 @@ pub fn parameter_values(tool: Tool, draft: &Draft) -> Result Result { for (key, value) in [ - ("start_x", a.x), ("start_y", a.y), - ("end_x", b.x), ("end_y", b.y), + ("start_x", a.x), + ("start_y", a.y), + ("end_x", b.x), + ("end_y", b.y), ] { p.insert(key.to_owned(), literal_cm(value)); } @@ -136,7 +146,10 @@ pub fn parameter_values(tool: Tool, draft: &Draft) -> Result { 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))); + 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)); @@ -207,8 +220,12 @@ fn decode_generated_text(raw: &str) -> String { (r"\textbackslash{}", r"\"), (r"\textasciicircum{}", "^"), (r"\textasciitilde{}", "~"), - (r"\{", "{"), (r"\}", "}"), (r"\$", "$"), - (r"\&", "&"), (r"\%", "%"), (r"\#", "#"), + (r"\{", "{"), + (r"\}", "}"), + (r"\$", "$"), + (r"\&", "&"), + (r"\%", "%"), + (r"\#", "#"), (r"\_", "_"), ] { value = value.replace(encoded, literal); @@ -324,9 +341,13 @@ fn unescaped_comment(line: &str) -> &str { /// 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; }; + 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; }; + let Some((_, remaining)) = options.split_once(']') else { + return false; + }; rest = remaining; } let Some((names, _)) = rest.strip_prefix('{').and_then(|body| body.split_once('}')) else { @@ -337,7 +358,9 @@ fn has_declaration(line: &str, command: &str, name: &str) -> bool { 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)) + commands + .iter() + .any(|command| has_declaration(&compact, command, name)) }) } pub fn tikz_loaded(source: &str) -> bool { @@ -400,7 +423,12 @@ struct Patch { inverse: SourcePatch, after_fingerprint: u64, } -fn minimal_changed_span(before: &str, after: &str, at: usize, rev: u64) -> Result { +fn minimal_changed_span( + before: &str, + after: &str, + at: usize, + rev: u64, +) -> Result { if before == after { return Err("No parameter change".into()); } @@ -413,14 +441,22 @@ fn minimal_changed_span(before: &str, after: &str, at: usize, rev: u64) -> Resul 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; } + 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, + range: at + prefix..at + old_end, expected_old: before[prefix..old_end].to_owned(), replacement: after[prefix..new_end].to_owned(), source_revision: rev, @@ -449,8 +485,13 @@ impl Session { 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.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. @@ -461,8 +502,13 @@ impl Session { 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.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(); } } @@ -473,10 +519,14 @@ impl Session { 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) + 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 }); + self.undo.push(Patch { + inverse, + after_fingerprint: self.source_fingerprint, + }); Ok(()) } pub fn undo_available(&self) -> bool { @@ -490,14 +540,21 @@ impl Session { self.draft = item.draft; Ok(item.line) } - pub fn insert(&mut self, source: &mut String, tool: Tool, draft: Draft) -> Result { + 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")); + return Err(format!( + "Missing project \\usetikzlibrary{{{library}}}; review source diff first" + )); } } let at = insertion_offset(source)?; @@ -508,17 +565,23 @@ impl Session { 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.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()); + 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 { @@ -534,7 +597,9 @@ impl Session { Ok(old.line) } pub fn propose_usepackage(source: &str) -> Result { - if tikz_loaded(source) { return Err("TikZ is already explicitly loaded".into()); } + 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))) } @@ -543,16 +608,23 @@ impl Session { 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.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()); + 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")); @@ -565,10 +637,15 @@ impl Session { 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.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(()) } @@ -577,7 +654,10 @@ impl Session { 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())?; + 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 { @@ -758,7 +838,11 @@ mod tests { 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()); + 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(); @@ -770,8 +854,14 @@ mod tests { #[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!(library_loaded( + r"\usetikzlibrary{calc,arrows.meta}", + "arrows.meta" + )); + assert!(!library_loaded( + "% \\usetikzlibrary{arrows.meta}", + "arrows.meta" + )); assert!(!tikz_loaded(r"\myLoadTikZ")); } @@ -780,13 +870,20 @@ mod tests { 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() }; + 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() }; + 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); @@ -799,7 +896,9 @@ mod tests { 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(); + 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(); @@ -816,7 +915,9 @@ mod tests { let mut source = SOURCE.to_owned(); let mut session = Session::default(); session.reset(&source); - session.insert(&mut source, Tool::Circle, Draft::default()).unwrap(); + 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(); @@ -824,5 +925,4 @@ mod tests { 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 index 64e2f8c0..0af8d416 100644 --- a/crates/tikzarium-app/src/tikz_canvas.rs +++ b/crates/tikzarium-app/src/tikz_canvas.rs @@ -413,17 +413,29 @@ pub fn show_inspector( info.requirements.engine, info.requirements.documented_against, info.requirements.pgf_version_min )); - ui.small(format!("Первоисточник: {} · {}", info.manual_section, info.manual_url)); + 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.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)); + ui.small(format!( + "PGF Manual: {} · {}", + parameter.manual_section, parameter.manual_url + )); }); } } @@ -460,7 +472,9 @@ pub fn show_inspector( session.status = error; } } - Err(error) => { ui.small(error); } + Err(error) => { + ui.small(error); + } } } if ui From 46c1c94a6f838859e89de168882364380bf12a94 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 12:46:20 +0300 Subject: [PATCH 9/9] docs(B08): record actual XeLaTeX fixture proof and isolated C07 CI blocker --- crates/tikzarium-app/QA_B08_LIBRARY_HELP.md | 3 +++ insights/2026-10-31_B08_CATALOG_INTEGRATION_QA.md | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/crates/tikzarium-app/QA_B08_LIBRARY_HELP.md b/crates/tikzarium-app/QA_B08_LIBRARY_HELP.md index 6b9d2466..eb0d640d 100644 --- a/crates/tikzarium-app/QA_B08_LIBRARY_HELP.md +++ b/crates/tikzarium-app/QA_B08_LIBRARY_HELP.md @@ -25,3 +25,6 @@ Check Light/Dark/Midnight/Paper and compact/comfortable persistence separately f - 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/insights/2026-10-31_B08_CATALOG_INTEGRATION_QA.md b/insights/2026-10-31_B08_CATALOG_INTEGRATION_QA.md index 93dc1888..ed180cf0 100644 --- a/insights/2026-10-31_B08_CATALOG_INTEGRATION_QA.md +++ b/insights/2026-10-31_B08_CATALOG_INTEGRATION_QA.md @@ -28,3 +28,10 @@ Open QA: verify source edit cursor/focus/undo after typing, native Save/Open wit ## 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.