diff --git a/.github/workflows/b42-studio-x11.yml b/.github/workflows/b42-studio-x11.yml new file mode 100644 index 00000000..bd9dc6ed --- /dev/null +++ b/.github/workflows/b42-studio-x11.yml @@ -0,0 +1,63 @@ +name: B42 real Studio icon palettes screenshot audit (bootstrap only) +on: + pull_request: + branches: [main] + paths: + - 'crates/tikzarium-app/**' + - 'scripts/qa_b42_studio_x11.py' + - '.github/workflows/b42-studio-x11.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + actual-studio: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + CARGO_PROFILE_DEV_DEBUG: '0' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec + with: + toolchain: '1.91.0' + - name: Install hosted X11 desktop prerequisites (not physical OS) + run: | + sudo apt-get update -q + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + xvfb xauth openbox xdotool imagemagick libgl1-mesa-dri libglx-mesa0 \ + libxkbcommon-dev libxkbcommon-x11-dev libx11-dev libxi-dev \ + libxcursor-dev libxrandr-dev libudev-dev + - name: Parse user-server PowerShell audit without running it + shell: bash + run: | + # This only checks syntax on the hosted Linux runner. + # It does not run MiKTeX or certify the dedicated Windows Server. + pwsh -NoProfile -NonInteractive -Command ' + $tokens=$null; $errors=$null + [System.Management.Automation.Language.Parser]::ParseFile( + "scripts/tools/qa_windows_server_user.ps1", + [ref]$tokens, [ref]$errors) | Out-Null + if($errors.Count -ne 0) { + $errors | ForEach-Object { Write-Error $_ } + exit 1 + } + Write-Host "PowerShell AST syntax PASS; Windows Server runtime NOT_RUN" + ' + - name: Compile the actual Rust/egui executable from locked sources + run: | + cargo build --locked -p tikzarium-app --bin tikzarium-app + git diff --exit-code -- Cargo.lock + - name: Capture actual Studio and floating grouped palette from X11 + run: | + mkdir -p "$RUNNER_TEMP/b42-runtime" + chmod 700 "$RUNNER_TEMP/b42-runtime" + export XDG_RUNTIME_DIR="$RUNNER_TEMP/b42-runtime" + xvfb-run -a -s '-screen 0 1440x900x24 +extension GLX +render -noreset' \ + bash -c 'openbox --sm-disable >/tmp/b42-openbox.log 2>&1 & python3 scripts/qa_b42_studio_x11.py --app "$GITHUB_WORKSPACE/target/debug/tikzarium-app" --out "$RUNNER_TEMP/b42-evidence"' + - name: Upload actual screenshots and explicit NOT_RUN ledger + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: b42-real-studio-${{ github.run_id }} + path: ${{ runner.temp }}/b42-evidence/ + if-no-files-found: warn diff --git a/.github/workflows/i46-b34-dirty-open-x11.yml b/.github/workflows/i46-b34-dirty-open-x11.yml index 9b31131c..8d996b24 100644 --- a/.github/workflows/i46-b34-dirty-open-x11.yml +++ b/.github/workflows/i46-b34-dirty-open-x11.yml @@ -1,3 +1,5 @@ +# Legacy B41 coordinate profile; the current B42 Studio uses I61 real-X11 source/PDF QA. +# Historical B41 branch/explicit dispatch remain available with exact B41 provenance. name: I51 B37 real X11 dirty-Open preserve user TeX and PDF on: push: @@ -10,7 +12,6 @@ on: - qa/i54-b39-n2-n3-desktop-reaccept - qa/i60-b41-inspector-real-x11 - b41-egui-pinned-parameter-apply - - main paths: - '.github/workflows/i46-b34-dirty-open-x11.yml' - 'scripts/qa_i46_dirty_open_x11.py' @@ -30,7 +31,6 @@ on: - b40-egui-source-undo-transaction-fence - b/b32-safe-open-source-undo-20261101 - b/b36-compact-pdf-focus - - main paths: - '.github/workflows/i46-b34-dirty-open-x11.yml' - 'scripts/qa_i46_dirty_open_x11.py' diff --git a/.github/workflows/i53-b38-live-libraries.yml b/.github/workflows/i53-b38-live-libraries.yml index 62cbbbc9..52d55f02 100644 --- a/.github/workflows/i53-b38-live-libraries.yml +++ b/.github/workflows/i53-b38-live-libraries.yml @@ -1,3 +1,5 @@ +# Legacy B41 coordinate profile; the current B42 Studio uses I61 real-X11 source/PDF QA. +# Historical B41 branch/explicit dispatch remain available with exact B41 provenance. name: I53 B39 actual X11 library Connect Disconnect PDF Undo and GUI Reopen on: push: @@ -8,7 +10,6 @@ on: - feat/b38-n2-n3-inspector-20261101 - qa/i60-b41-inspector-real-x11 - b41-egui-pinned-parameter-apply - - main workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/i57-n2-four-live-x11.yml b/.github/workflows/i57-n2-four-live-x11.yml index f3e5a332..0d69ef3b 100644 --- a/.github/workflows/i57-n2-four-live-x11.yml +++ b/.github/workflows/i57-n2-four-live-x11.yml @@ -1,7 +1,9 @@ +# Legacy B41 coordinate profile; the current B42 Studio uses I61 real-X11 source/PDF QA. +# Historical B41 branch/explicit dispatch remain available with exact B41 provenance. name: I57 N2 four actual X11 library source PDF Undo Open and C help on: pull_request: - branches: [main, b40-egui-source-undo-transaction-fence, b41-egui-pinned-parameter-apply] + branches: [b40-egui-source-undo-transaction-fence, b41-egui-pinned-parameter-apply] paths: - '.github/workflows/i57-n2-four-live-x11.yml' - 'scripts/qa_i57_four_libs_x11.py' @@ -12,7 +14,7 @@ on: - 'crates/**' - 'Cargo.lock' push: - branches: [main, qa/i58-b40-source-history-x11, qa/i60-b41-inspector-real-x11, b41-egui-pinned-parameter-apply] + branches: [qa/i58-b40-source-history-x11, qa/i60-b41-inspector-real-x11, b41-egui-pinned-parameter-apply] paths: - '.github/workflows/i57-n2-four-live-x11.yml' - 'scripts/qa_i57_four_libs_x11.py' diff --git a/.github/workflows/i61-b42-studio-x11.yml b/.github/workflows/i61-b42-studio-x11.yml new file mode 100644 index 00000000..94ca9737 --- /dev/null +++ b/.github/workflows/i61-b42-studio-x11.yml @@ -0,0 +1,86 @@ +# B42 six native GUI gestures, persisted source, external XeLaTeX PDF. +# Exact B42 source-tree lease is fail-closed. Current Linux/X11 probe includes real Open/Undo/F1; Inspector Apply and physical OS remain NOT_RUN until independently accepted. +name: I61 B42 real Studio icon-to-native-source/PDF (independent D QA) +on: + push: + branches: [qa/i61-b42-studio-source-pdf, main] + paths: + - '.github/workflows/i61-b42-studio-x11.yml' + - 'scripts/qa_i61_b42_studio_x11.py' + - 'scripts/qa_n1_desktop.py' + - 'scripts/tests/test_qa_i61_studio_fonts.py' + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + pull_request: + branches: [main] + paths: + - '.github/workflows/i61-b42-studio-x11.yml' + - 'scripts/qa_i61_b42_studio_x11.py' + - 'scripts/qa_n1_desktop.py' + - 'scripts/tests/test_qa_i61_studio_fonts.py' + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: i61-studio-${{ github.event.pull_request.head.sha || github.sha }} + cancel-in-progress: true +jobs: + studio-full-vertical: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + CARGO_PROFILE_DEV_DEBUG: '0' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec + with: + toolchain: '1.91.0' + - name: Exact B42 app source-tree lease (fail closed before GUI) + run: | + test "$(git rev-parse HEAD:crates/tikzarium-app/src)" = '72499c6e31d4cf6a2a36d16fb7bc8b10a9d66acb' + # Catch disagreement between workflow and Python QA before heavy TeX install. + grep -Fqx "APP_TREE = \"$(git rev-parse HEAD:crates/tikzarium-app/src)\"" scripts/qa_i61_b42_studio_x11.py + echo "TIKZARIUM_APP_TREE_SHA=$(git rev-parse HEAD:crates/tikzarium-app/src)" >> "$GITHUB_ENV" + echo "TIKZARIUM_SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + git diff --exit-code -- Cargo.lock + - name: Font-audit offline contract tests (NOT GUI) + run: python3 -m unittest discover -s scripts/tests -p test_qa_i61_studio_fonts.py -v + - name: Hosted Linux/X11 and external TeX Live XeLaTeX prerequisites + run: | + # Exact f3aa1ea run cancelled after 45min downloading 129MB apt packages. + # Avoid the observed severely slow Azure archive and bound HTTP stalls. + if test -f /etc/apt/apt-mirrors.txt; then + sudo sed -i 's#http://azure.archive.ubuntu.com/ubuntu/#http://archive.ubuntu.com/ubuntu/#g' /etc/apt/apt-mirrors.txt + fi + sudo apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=25 update -q + sudo DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=25 install -y --no-install-recommends \ + xvfb xauth openbox xdotool xclip imagemagick libgl1-mesa-dri libglx-mesa0 \ + libxkbcommon-dev libxkbcommon-x11-dev libx11-dev libxi-dev \ + libxcursor-dev libxrandr-dev libudev-dev \ + texlive-xetex texlive-latex-extra texlive-pictures poppler-utils fonts-dejavu-core + xelatex --version | head -n 2 + command -v pdffonts + - name: Compile locked production egui binary, no QA stub + run: | + cargo build --locked -p tikzarium-app --bin tikzarium-app + git diff --exit-code -- Cargo.lock + - name: Real group icon, all six pointers, source Save and XeLaTeX PDF + run: | + mkdir -p "$RUNNER_TEMP/i61-runtime" + chmod 700 "$RUNNER_TEMP/i61-runtime" + export XDG_RUNTIME_DIR="$RUNNER_TEMP/i61-runtime" + xvfb-run -a -s '-screen 0 1440x900x24 +extension GLX +render -noreset' \ + bash -c 'openbox --sm-disable >/tmp/i61-openbox.log 2>&1 & python3 scripts/qa_i61_b42_studio_x11.py --app "$GITHUB_WORKSPACE/target/debug/tikzarium-app" --out "$RUNNER_TEMP/i61-evidence"' + - name: Upload actual GUI screenshots, saved source, PDF, log and SHA ledger + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: i61-b42-source-pdf-${{ github.run_id }} + path: ${{ runner.temp }}/i61-evidence/ + if-no-files-found: warn diff --git a/.github/workflows/n1-desktop-smoke.yml b/.github/workflows/n1-desktop-smoke.yml index 0ece0cc6..a5413825 100644 --- a/.github/workflows/n1-desktop-smoke.yml +++ b/.github/workflows/n1-desktop-smoke.yml @@ -1,3 +1,5 @@ +# Legacy B41 coordinate profile; the current B42 Studio uses I61 real-X11 source/PDF QA. +# Historical B41 branch/explicit dispatch remain available with exact B41 provenance. name: I51 B37 real X11 native TikZ six gestures, held Undo and PDF on: push: @@ -12,7 +14,6 @@ on: - qa/i54-b39-n2-n3-desktop-reaccept - qa/i60-b41-inspector-real-x11 - b41-egui-pinned-parameter-apply - - main paths: - '.github/workflows/n1-desktop-smoke.yml' - 'scripts/qa_n1_desktop.py' @@ -35,7 +36,6 @@ on: - b/b32-safe-open-source-undo-20261101 - feat/b27-egui-undo-held-pointer-20261101 - b/b31-compact-xelatex-fit - - main paths: - '.github/workflows/n1-desktop-smoke.yml' - 'scripts/qa_n1_desktop.py' diff --git a/.github/workflows/qa-selfhosted-winserver.yml b/.github/workflows/qa-selfhosted-winserver.yml new file mode 100644 index 00000000..979b7f03 --- /dev/null +++ b/.github/workflows/qa-selfhosted-winserver.yml @@ -0,0 +1,42 @@ +name: Manual user Windows Server MiKTeX QA (not Win10) + +on: + workflow_dispatch: + inputs: + expected_main_sha: + description: "The exact reviewed main SHA; no PR code on persistent runner" + required: true + type: string + +permissions: + contents: read + +jobs: + user-windows-server: + if: github.ref == 'refs/heads/main' + runs-on: [self-hosted, Windows, X64, tikzarium-winserver] + timeout-minutes: 55 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Verify expected main SHA before any user-server execution + shell: pwsh + env: + EXPECTED_SHA: ${{ inputs.expected_main_sha }} + run: | + $actual = (git rev-parse HEAD).Trim() + if ($actual -cne $env:EXPECTED_SHA -or $actual -cne '${{ github.sha }}') { + throw 'Exact main SHA lease mismatch; refuse to run on user server' + } + - name: User MiKTeX offline proof and locked Rust test + shell: pwsh + run: | + ./scripts/tools/qa_windows_server_user.ps1 -OutputDir "$env:RUNNER_TEMP\tikzarium-user-miktex" -CheckRust + - name: Upload honest evidence including failure + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: tikzarium-user-winserver-${{ github.run_id }} + path: ${{ runner.temp }}/tikzarium-user-miktex/ + if-no-files-found: warn diff --git a/CHANGELOG.md b/CHANGELOG.md index f9280839..0158d3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,41 @@ -## Unreleased — I60 independent B41 real-X11 inspector acceptance (PR #90; PDF preview/F1 recheck pending) +# Changelog + +## Unreleased — I64 real pinned Inspector Apply → one minimal TikZ source edit → Undo (PENDING) + +- Extend the SAME I61 hosted Linux/X11 six gestures/Save/Open/CRLF/F1/Undo/XeLaTeX script with real selected Node property TextEdit and pinned shared-C Apply, prove draft-only source integrity, one-command patch and exact visual Undo. No new workflow/PR/B code. I63 earlier head SUCCESS and original 64/64 SHA/manual reviewed; new I64 head acceptance pending, physical Win10/M1/M4 NOT_RUN. + +## Unreleased — I63 dual B42 source-tree lease & real File Open focus snapshots + +- Pin workflow + script to current B42 app/source Git tree `72499c6e31d4cf6a2a36d16fb7bc8b10a9d66acb`; reject mismatches before TeX Live installation. Capture real focus/Ctrl+A/paste screenshots while retaining all six native gestures, source/Undo/Open/PDF assertions. NOT_RUN full GUI and physical hosts until exact-head artifact audit. + +## Unreleased — I63 B42 P0 File Open integration lease (actual X11 NOT_RUN at commit) + +- Merge frozen B42 File TextEdit click-to-edit production repair into existing stacked #92 as a second-parent exact SHA, preserving all prior D QA changes; move I61 app-source-tree guard to `72499c6e31d4cf6a2a36d16fb7bc8b10a9d66acb`. Do not disable source-tree provenance or replace external TeX Live with MiKTeX in Linux/X11 QA. +- B42 exact-head Rust/Clippy/D QA/hosted MiKTeX/N1 XeLaTeX/fonts passed before re-lease; product popup fix is UNIT_EVENT until real two-file Open/edit/Save/reopen and dirty Cancel succeed with original PNG/source/PDF hashes reviewed. Inspector Apply and physical Win10/M1/M4 remain NOT_RUN. No merge to main. + +## Unreleased — I62 real N1 GUI Help, Undo and two-file I/O acceptance (QA ONLY) + +- Extend existing stacked #92 independent Linux/X11 driver to test actual F1 open/close, TextEdit Ctrl+Z, visual Undo, second CRLF/opaque-source GUI Open/Save/Reopen and dirty Open cancel; separate NOT_RUN until exact-head CI and manual artifacts. No B/C/A production edits. +- Diagnose exact I61 QA head f3aa1ea7 cancellation before GUI (45-minute Azure apt mirror stall); use canonical Ubuntu archive and bounded package retries/HTTP timeouts, never replace external XeLaTeX or waive evidence. + +## Unreleased — I61 independent real B42 acceptance (six-gesture subvertical PASS; N1 NOT_ACCEPTED) + +- CI transition: historical B41 I51/I53/I57 fixed-tree X11 workflows remain manually runnable at matching B41 revision, no automatic false-red main runs; I61 exact-B42 six-gesture/source/PDF regression now runs for main PRs and pushes. Real B42 Open/Undo/Apply/F1 and physical targets are still unaccepted release gates. + +- Re-lease existing #92 stacked QA onto B42 production HEAD `91e2d8201cd0` with native C six-gesture X11 driver; no changes to A/B/C/E/F production. Exact app-tree provenance remains fail-closed. +- Replace broken doubled-escape `pdffonts` parser with all-font embedding audit; four offline positive/negative tests run before installing external XeLaTeX. Actual GUI source/Save/Open/Undo/help/PDF is NOT_RUN until new exact-head artifact acceptance. Physical Win10/Mac ARM NOT_RUN. + + +## Unreleased — B42 redesigned Studio and dedicated user's MiKTeX Windows Server QA (PENDING) + +- Follow-up from real B42 X11 screenshot review: restore a dark Midnight default and readable 14pt source/body fonts, relocate grouped palette away from source, search the live C registry instead of hard-coded packages, and keep manual PDF zoom stable when selecting PDF. Exact-head reacceptance PENDING. + +- Replaced repeated long tool buttons with native egui vector icons in four grouped floating palettes. No font-file asset or second PGF renderer; native C-catalogue commands and source-first semantics retained. +- Added a concise icon toolbar, permanently editable TeX beside the center drawing/PDF, independent Properties/Libraries/Help tabs, persistent workspace preferences and collapsed-by-default diagnostics. Classic UI remains temporarily available for regression comparisons. +- Added a manual main-SHA-gated GitHub Actions test for a dedicated user Windows Server runner and a no-auto-install MiKTeX/locked-Rust proof script. Runner connectivity and physical Windows10/M1/M4 are NOT_RUN; this is not GUI Session proof. +- Exact-head Rust/GUI/Windows CI and separate post-merge CI NOT_RUN at authoring. No release claim. + +d — I60 independent B41 real-X11 inspector acceptance (PR #90; PDF preview/F1 recheck pending) - Extend the same stacked I60 QA gate to re-run existing I46 dirty Open, I47 six held-pointer gestures, I53 positioning/help and I57 four-library real-X11 workflows on the **exact B41 app/src tree**, preserving fail-closed source/PDF/hash predicates and explicitly provisional B40-derived coordinates. Old head X11 failures remain NOT_RUN until new exact QA head plus manual artifact review. No production-code changes. - Add a separate exact-B41-source-tree Linux/X11 workflow exercising the **real egui window**: native Circle pointer → actual property TextEdit → unchanged source while draft pending → pinned C-backed Apply changing only selected command and metadata → native TextEdit Ctrl+Z history fence → visual Undo → Save/Open two distinguishable files including a CRLF/opaque Cyrillic source, followed by GUI external XeLaTeX PDF. Captures immutable sources, PDF, screenshots and per-file SHA-256; no production Rust/C or external TeX package installation changes. diff --git a/crates/tikzarium-app/src/main.rs b/crates/tikzarium-app/src/main.rs index c34f5f51..c31de61e 100644 --- a/crates/tikzarium-app/src/main.rs +++ b/crates/tikzarium-app/src/main.rs @@ -15,6 +15,7 @@ mod font_proof; mod pdf_navigation; mod preview_image; mod source_fonts; +mod studio_ui; mod tikz_authoring; mod tikz_canvas; mod tikz_libraries; @@ -31,9 +32,14 @@ use pdf_navigation::{ }; use preview_image::{PreviewLodReceiver, decode_preview_png, spawn_preview_lod}; use source_fonts::scan_explicit_font_hints; +use studio_ui::{StudioDock, StudioGroup, StudioIcon}; use ui_theme::{STORAGE_KEY, UiPreferences}; const QUICKSTART: &str = include_str!("../../../examples/quickstart.tex"); +// The Linux Save action cannot use a native dialog: this is an actual, editable +// initial destination, never just a hint. typed_save_destination still forbids +// overwriting a pre-existing file without a deliberate path change. +const DEFAULT_SCRATCH_FILENAME: &str = "figure.tex"; // Fixed 500pt source + 250pt inspector left the exact PDF/guide unusable // at the native 850pt minimum window width. Reserve central space first. @@ -77,13 +83,15 @@ fn show_split_source_panel( ctx: &egui::Context, layout: SplitPanelLayout, add_contents: impl FnOnce(&mut egui::Ui), -) { +) -> egui::Rect { egui::SidePanel::left("source") .resizable(true) .default_width(layout.source_default) .min_width(240.0) .max_width(layout.source_max) - .show(ctx, add_contents); + .show(ctx, add_contents) + .response + .rect } /// Fit an entire rasterized XeLaTeX PDF page in the actual remaining preview @@ -357,6 +365,25 @@ fn pending_open_save_destination( Ok(save_path) } +/// A dirty scratch must retain its own safe Save-and-open destination even +/// after the File menu field has been changed to the file being opened. +/// Never suggest a path that is occupied or aliases the incoming target. +fn initial_pending_open_save_path( + document: &Document, + open_target: &Path, + scratch_candidate: &Path, +) -> String { + let candidate = document.path.as_deref().unwrap_or(scratch_candidate); + let Some(text) = candidate.to_str() else { + return String::new(); + }; + if pending_open_save_destination(document, text, open_target).is_ok() { + text.to_owned() + } else { + String::new() + } +} + /// Real egui controls; returning the save-button rect additionally allows /// pointer event-frame tests to exercise the exact production click handler. fn pending_open_controls( @@ -466,6 +493,9 @@ struct TikzariumApp { pending_open_save_path: String, pending_open_validation_error: Option, prefs: UiPreferences, + studio_group: Option, + studio_palette_anchor: egui::Pos2, + studio_extension_filter: String, authoring: tikz_authoring::Session, highlight_generated_line: bool, // A source patch or document replacement must not leave native TextEdit @@ -477,6 +507,83 @@ struct TikzariumApp { font_proof_texture: Option, } +// B42: the path TextEdit must not close its menu just by gaining focus. +// egui 0.33 defaults to CloseOnClick, including clicks inside the menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StudioFileAction { + OpenPath, + SaveAs, +} + +struct StudioFileMenuResponse { + action: Option, + #[cfg(test)] + trigger_rect: egui::Rect, + #[cfg(test)] + path_rect: Option, + #[cfg(test)] + open_rect: Option, +} + +fn show_studio_file_menu( + ui: &mut egui::Ui, + file_field: &mut String, + ready: bool, +) -> StudioFileMenuResponse { + let mut result = StudioFileMenuResponse { + action: None, + #[cfg(test)] + trigger_rect: egui::Rect::NOTHING, + #[cfg(test)] + path_rect: None, + #[cfg(test)] + open_rect: None, + }; + let (trigger, _) = egui::containers::menu::MenuButton::new("Файл") + .config( + egui::containers::menu::MenuConfig::default() + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside), + ) + .ui(ui, |ui| { + ui.label("TeX-проект:"); + let path_edit = ui.add( + egui::TextEdit::singleline(file_field) + .desired_width(260.0) + .hint_text("figure.tex"), + ); + #[cfg(test)] + { + result.path_rect = Some(path_edit.rect); + } + #[cfg(not(test))] + let _ = path_edit; + + let open = ui.add_enabled(ready, egui::Button::new("Открыть путь")); + #[cfg(test)] + { + result.open_rect = Some(open.rect); + } + if open.clicked() { + result.action = Some(StudioFileAction::OpenPath); + ui.close(); + } + if ui + .add_enabled(ready, egui::Button::new("Сохранить как…")) + .clicked() + { + result.action = Some(StudioFileAction::SaveAs); + ui.close(); + } + }); + #[cfg(test)] + { + result.trigger_rect = trigger.rect; + } + #[cfg(not(test))] + let _ = trigger; + result +} + impl TikzariumApp { fn new(cc: &eframe::CreationContext<'_>) -> Self { let prefs = cc @@ -489,7 +596,7 @@ impl TikzariumApp { let package_statuses = registry.status(&tex); // cache; never spawn kpsewhich every frame Self { document: Document::scratch(QUICKSTART), - file_field: String::new(), + file_field: DEFAULT_SCRATCH_FILENAME.to_owned(), tex, package_statuses, diagnostics: Vec::new(), @@ -498,7 +605,7 @@ impl TikzariumApp { jump_to_line: None, last_build_failed: false, log: String::new(), - status: "Save the starter as a .tex file, then Build".into(), + status: "Сохраните новый рисунок как .tex, затем соберите PDF".into(), preview_status: "No exact preview yet".into(), preview_texture: None, preview_lod_texture: None, @@ -526,6 +633,9 @@ impl TikzariumApp { pending_open_save_path: String::new(), pending_open_validation_error: None, prefs, + studio_group: None, + studio_palette_anchor: egui::pos2(65.0, 120.0), + studio_extension_filter: String::new(), authoring: { let mut state = tikz_authoring::Session::default(); state.reset(QUICKSTART); @@ -551,12 +661,11 @@ impl TikzariumApp { } if self.document.dirty() { self.pending_open_validation_error = None; - self.pending_open_save_path = self - .document - .path - .as_ref() - .map(|current| current.display().to_string()) - .unwrap_or_default(); + self.pending_open_save_path = initial_pending_open_save_path( + &self.document, + &path, + Path::new(DEFAULT_SCRATCH_FILENAME), + ); self.pending_open = Some(path); return; } @@ -975,7 +1084,7 @@ impl TikzariumApp { if self.pending_open.is_some() { ui.disable(); } - ui.heading("TeX / TikZ source"); + ui.heading("Исходный TikZ / TeX"); ui.small( self.document .path @@ -1097,12 +1206,290 @@ impl TikzariumApp { ); } + /// The main toolbar is four clear vector icons, not a long wall of + /// textual action buttons. Infrequent commands live in two menus. + fn show_studio_toolbar(&mut self, ctx: &egui::Context) { + let ready = self.receiver.is_none() && self.pending_open.is_none(); + egui::TopBottomPanel::top("b42-studio-toolbar").show(ctx, |ui| { + if self.pending_open.is_some() { + ui.disable(); + } + ui.horizontal(|ui| { + ui.strong(egui::RichText::new("TikZarium").size(20.0)); + ui.separator(); + if studio_ui::icon_button(ui, StudioIcon::Open, false, 34.0, "Открыть TeX · Ctrl+O") + .clicked() + && ready + { + self.open_document_dialog(); + } + if studio_ui::icon_button( + ui, + StudioIcon::Save, + false, + 34.0, + "Сохранить исходник · Ctrl+S", + ) + .clicked() + && ready + { + self.save(); + } + if studio_ui::icon_button( + ui, + StudioIcon::Build, + self.receiver.is_some(), + 34.0, + "Собрать точный PDF через внешний XeLaTeX · Ctrl+Enter", + ) + .clicked() + && ready + { + self.build(); + } + if studio_ui::icon_button( + ui, + StudioIcon::Export, + false, + 34.0, + "Экспорт PDF. Только актуальный результат XeLaTeX.", + ) + .clicked() + && self.pdf.is_some() + && !self.preview_stale() + { + self.export(); + } + if self.receiver.is_some() + && ui + .small_button("■") + .on_hover_text("Отменить сборку") + .clicked() + && let Some(cancel) = &self.cancellation + { + cancel.cancel(); + } + ui.separator(); + match show_studio_file_menu(ui, &mut self.file_field, ready).action { + Some(StudioFileAction::OpenPath) => { + self.open_path(PathBuf::from(self.file_field.trim())); + } + Some(StudioFileAction::SaveAs) => { + self.save_as(); + } + None => {} + } + ui.menu_button("Вид", |ui| { + if ui + .checkbox(&mut self.prefs.studio_tool_rail, "Группы инструментов") + .changed() + && !self.prefs.studio_tool_rail + { + // A hidden rail must not leave a floating tool palette. + self.studio_group = None; + } + ui.checkbox(&mut self.prefs.studio_inspector_open, "Инспектор"); + ui.checkbox(&mut self.prefs.studio_console_open, "Диагностика"); + ui.separator(); + self.prefs.draw_toolbar(ui, ctx); + if ui.button("Классический режим").clicked() { + self.prefs.studio_mode = false; + ui.close(); + } + if ui.button("Шрифты рисунка").clicked() { + self.prefs.show_font_lab = true; + ui.close(); + } + }); + if studio_ui::icon_button( + ui, + StudioIcon::Help, + self.prefs.studio_dock == StudioDock::Help && self.prefs.studio_inspector_open, + 32.0, + "Контекстная справка F1 из общего TikZ-каталога", + ) + .clicked() + { + studio_ui::toggle_studio_help_button( + &mut self.authoring, + &mut self.prefs.studio_dock, + &mut self.prefs.studio_inspector_open, + ); + } + if studio_ui::icon_button( + ui, + StudioIcon::Console, + self.prefs.studio_console_open, + 32.0, + "Показать или скрыть диагностику и журнал XeLaTeX", + ) + .clicked() + { + self.prefs.studio_console_open = !self.prefs.studio_console_open; + } + }); + ui.horizontal(|ui| { + let prior_focus = self.pdf_focus_in_split; + ui.selectable_value(&mut self.pdf_focus_in_split, false, "Эскиз"); + ui.selectable_value(&mut self.pdf_focus_in_split, true, "PDF"); + if self.pdf_focus_in_split && !prior_focus { + self.fit_page_active = true; + self.authoring.cancelled_pointer_capture |= + self.authoring.guide_pointer_armed || self.authoring.drag_start.is_some(); + self.authoring.guide_pointer_armed = false; + self.authoring.drag_start = None; + self.authoring.alt_selection_capture = false; + } + ui.separator(); + let filename = self + .document + .path + .as_ref() + .map_or_else(|| "Новый .tex".to_owned(), |p| p.display().to_string()); + ui.add(egui::Label::new(filename).truncate()); + if self.document.dirty() { + ui.colored_label(ui.visuals().warn_fg_color, "● Изменено"); + } + if self.preview_stale() { + ui.colored_label(ui.visuals().warn_fg_color, "PDF устарел"); + } + }); + }); + } + + /// One contextual dock at a time. Unlike the legacy UI, library inventory, + /// parameter editing and full help do not stack into one endless scroll. + fn show_studio_inspector(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + for tab in StudioDock::ALL { + ui.selectable_value(&mut self.prefs.studio_dock, tab, tab.label()); + } + }); + ui.separator(); + if self.authoring.active.is_some() || self.authoring.undo_available() { + let prior = self.authoring.source_revision(); + if let Some(line) = tikz_canvas::show_inspector_action_bar( + ui, + &mut self.authoring, + &mut self.document.source, + ) { + self.jump_to_line = Some(line); + self.highlight_generated_line = true; + } + fence_source_editor_undo_on_revision_change( + prior, + &self.authoring, + &mut self.reset_source_editor_undo_history, + ); + ui.separator(); + } + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| match self.prefs.studio_dock { + StudioDock::Properties => { + if self.authoring.active.is_none() { + ui.heading("Выберите инструмент"); + ui.label("Нажмите на группу иконок слева, затем выберите команду TikZ."); + ui.small("Параметры и примеры появятся здесь из единого каталога."); + ui.separator(); + ui.small("Исходный .tex остаётся редактируемым. Точный результат — внешний XeLaTeX."); + } else { + let prior = self.authoring.source_revision(); + 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; + } + fence_source_editor_undo_on_revision_change( + prior, + &self.authoring, + &mut self.reset_source_editor_undo_history, + ); + } + } + StudioDock::Libraries => { + ui.heading("Библиотеки проекта"); + ui.small("Подключение — показанная обратимая правка преамбулы; не установка."); + let prior = self.authoring.source_revision(); + if let Some(line) = tikz_libraries::show_library_panel( + ui, + &mut self.authoring, + &mut self.document.source, + self.tex.xelatex.is_some(), + ) { + self.jump_to_line = Some(line); + self.highlight_generated_line = true; + } + fence_source_editor_undo_on_revision_change( + prior, + &self.authoring, + &mut self.reset_source_editor_undo_history, + ); + ui.collapsing("Реестр доступности пакетов", |ui| { + for item in &self.package_statuses { + ui.strong(&item.manifest.display_name); + ui.small(format!( + "Установка: {:?} · XeLaTeX: {:?} · GUI-адаптер: {}", + item.installation, + item.compilation, + if item.visual_editable { + "да" + } else { + "нет" + } + )); + } + }); + } + StudioDock::Help => { + tikz_libraries::show_help_search(ui, &mut self.help_query); + ui.separator(); + studio_ui::show_context_help(ui, &self.authoring); + } + }); + } + fn show_pdf_canvas(&mut self, ui: &mut egui::Ui) { + // Studio uses the full central area for ONE primary task: interactive + // TikZ authoring OR exact PDF. The previous 170pt pad above a huge + // empty preview was the principal ergonomic failure on 1366x768. + // Both modes keep the original editable code pane alongside them. + if self.prefs.studio_mode && !self.pdf_focus_in_split { + let prior = self.authoring.source_revision(); + let caps = self.primitive_capabilities(); + if let Some(line) = tikz_canvas::show_guide_studio( + ui, + &mut self.authoring, + &mut self.document.source, + caps, + ) { + self.jump_to_line = Some(line); + self.highlight_generated_line = true; + ui.ctx().request_repaint(); + } + fence_source_editor_undo_on_revision_change( + prior, + &self.authoring, + &mut self.reset_source_editor_undo_history, + ); + ui.separator(); + ui.small("Интерактивный эскиз TikZ. Точная геометрия и шрифты — во вкладке PDF после XeLaTeX."); + return; + } let canvas_width = ui.available_width(); let mut displayed_zoom_percent = pdf_zoom_percent(self.zoom); ui.horizontal_wrapped(|ui| { - ui.heading("Canvas · exact PDF (read-only)"); - if self.workspace == WorkspaceMode::Split { + ui.heading(if self.prefs.studio_mode { + "Рисунок / PDF" + } else { + "Canvas · exact PDF (read-only)" + }); + if self.workspace == WorkspaceMode::Split && !self.prefs.studio_mode { ui.label("Центр:"); show_split_center_focus_switch( ui, @@ -1178,13 +1565,23 @@ impl TikzariumApp { } ui.label(&self.preview_status); ui.small(&self.page_navigation_status); - ui.small("The guide pad edits N1-owned source through the shared TikZ catalogue. This exact PDF is read-only and is NOT coordinate-aligned with the guide."); + if !self.prefs.studio_mode { + ui.small("The guide pad edits N1-owned source through the shared TikZ catalogue. This exact PDF is read-only and is NOT coordinate-aligned with the guide."); + } if self.workspace == WorkspaceMode::Split && !self.pdf_focus_in_split { let caps = self.primitive_capabilities(); let previous_revision = self.authoring.source_revision(); - if let Some(line) = + let inserted = if self.prefs.studio_mode { + tikz_canvas::show_guide_studio( + ui, + &mut self.authoring, + &mut self.document.source, + caps, + ) + } else { tikz_canvas::show_guide(ui, &mut self.authoring, &mut self.document.source, caps) - { + }; + if let Some(line) = inserted { self.jump_to_line = Some(line); self.highlight_generated_line = true; // The source SidePanel has already been rendered this frame. @@ -1413,6 +1810,26 @@ impl TikzariumApp { } } +/// Reserve the Studio status row before any side/central panel consumes the +/// remaining viewport. The bottom bar is full width, and on-demand diagnostics +/// stack ABOVE it; editable source and provisional guide cannot render beneath +/// an after-the-fact CentralPanel overlay. +fn show_studio_status_panel(ctx: &egui::Context, status: &str, xelatex_ready: bool) -> egui::Rect { + egui::TopBottomPanel::bottom("b42-status") + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(status).small()); + if xelatex_ready { + ui.small("XeLaTeX: настроен"); + } else { + ui.colored_label(ui.visuals().warn_fg_color, "XeLaTeX не выбран"); + } + }); + }) + .response + .rect +} + impl eframe::App for TikzariumApp { fn save(&mut self, storage: &mut dyn eframe::Storage) { eframe::set_value(storage, STORAGE_KEY, &self.prefs); @@ -1427,7 +1844,16 @@ impl eframe::App for TikzariumApp { // Pending dirty-Open decisions own the keyboard: do not change source // through a global help/visual Undo shortcut behind the modal. if self.pending_open.is_none() { + let had_help = self.authoring.help; tikz_canvas::handle_help_shortcut(ctx, &mut self.authoring); + if self.prefs.studio_mode { + studio_ui::route_studio_help_toggle( + had_help, + self.authoring.help, + &mut self.prefs.studio_dock, + &mut self.prefs.studio_inspector_open, + ); + } if self.workspace == WorkspaceMode::Split && let Some(line) = tikz_canvas::handle_visual_undo_shortcut( ctx, @@ -1466,98 +1892,120 @@ impl eframe::App for TikzariumApp { } } let file_actions_enabled = self.receiver.is_none() && self.pending_open.is_none(); - egui::TopBottomPanel::top("tools").show(ctx, |ui| { - if self.pending_open.is_some() { - ui.disable(); - } - ui.horizontal_wrapped(|ui| { - ui.heading("TikZarium"); - if ui - .add_enabled(file_actions_enabled, egui::Button::new("Open")) - .on_hover_text("Ctrl+O / Cmd+O") - .clicked() - { - self.open_document_dialog(); - } - ui.label("Path:"); - ui.add( - egui::TextEdit::singleline(&mut self.file_field) - .desired_width(280.0) - .hint_text("path/to/figure.tex"), - ); - if ui - .add_enabled(file_actions_enabled, egui::Button::new("Open path")) - .clicked() - { - self.open_path(PathBuf::from(self.file_field.trim())); - } - if ui - .add_enabled(file_actions_enabled, egui::Button::new("Save")) - .on_hover_text("Ctrl+S / Cmd+S") - .clicked() - { - self.save(); - } - if ui - .add_enabled(file_actions_enabled, egui::Button::new("Save As…")) - .on_hover_text("Ctrl+Shift+S / Cmd+Shift+S") - .clicked() - { - self.save_as(); - } - if ui - .add_enabled(file_actions_enabled, egui::Button::new("Build (XeLaTeX)")) - .on_hover_text("Ctrl+Enter / Cmd+Enter") - .clicked() - { - self.build(); - } - if ui - .add_enabled(self.receiver.is_some(), egui::Button::new("Cancel")) - .clicked() - && let Some(cancel) = &self.cancellation - { - cancel.cancel(); - } - if ui - .add_enabled( - self.pdf.is_some() && !self.preview_stale(), - egui::Button::new("Export PDF"), - ) - .clicked() - { - self.export(); - } - if ui - .add_enabled(self.receiver.is_none(), egui::Button::new("Discover TeX")) - .clicked() - { - self.refresh_tex(); - } - }); - ui.horizontal_wrapped(|ui| { - ui.label("Workspace:"); - ui.selectable_value(&mut self.workspace, WorkspaceMode::Source, "Source"); - ui.selectable_value(&mut self.workspace, WorkspaceMode::Split, "Split"); - ui.selectable_value(&mut self.workspace, WorkspaceMode::Canvas, "Canvas"); - ui.separator(); - self.prefs.draw_toolbar(ui, ctx); - if ui.button("Figure fonts").clicked() { - self.prefs.show_font_lab = !self.prefs.show_font_lab; - } - 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"); - } - if self.preview_stale() { - ui.colored_label(egui::Color32::YELLOW, "PDF stale"); + if self.prefs.studio_mode { + self.workspace = WorkspaceMode::Split; + self.show_studio_toolbar(ctx); + } else { + egui::TopBottomPanel::top("tools").show(ctx, |ui| { + if self.pending_open.is_some() { + ui.disable(); } - ui.label(&self.status); + ui.horizontal_wrapped(|ui| { + ui.heading("TikZarium"); + if ui + .add_enabled(file_actions_enabled, egui::Button::new("Open")) + .on_hover_text("Ctrl+O / Cmd+O") + .clicked() + { + self.open_document_dialog(); + } + ui.label("Path:"); + ui.add( + egui::TextEdit::singleline(&mut self.file_field) + .desired_width(280.0) + .hint_text("path/to/figure.tex"), + ); + if ui + .add_enabled(file_actions_enabled, egui::Button::new("Open path")) + .clicked() + { + self.open_path(PathBuf::from(self.file_field.trim())); + } + if ui + .add_enabled(file_actions_enabled, egui::Button::new("Save")) + .on_hover_text("Ctrl+S / Cmd+S") + .clicked() + { + self.save(); + } + if ui + .add_enabled(file_actions_enabled, egui::Button::new("Save As…")) + .on_hover_text("Ctrl+Shift+S / Cmd+Shift+S") + .clicked() + { + self.save_as(); + } + if ui + .add_enabled(file_actions_enabled, egui::Button::new("Build (XeLaTeX)")) + .on_hover_text("Ctrl+Enter / Cmd+Enter") + .clicked() + { + self.build(); + } + if ui + .add_enabled(self.receiver.is_some(), egui::Button::new("Cancel")) + .clicked() + && let Some(cancel) = &self.cancellation + { + cancel.cancel(); + } + if ui + .add_enabled( + self.pdf.is_some() && !self.preview_stale(), + egui::Button::new("Export PDF"), + ) + .clicked() + { + self.export(); + } + if ui + .add_enabled(self.receiver.is_none(), egui::Button::new("Discover TeX")) + .clicked() + { + self.refresh_tex(); + } + }); + ui.horizontal_wrapped(|ui| { + ui.label("Workspace:"); + ui.selectable_value(&mut self.workspace, WorkspaceMode::Source, "Source"); + ui.selectable_value(&mut self.workspace, WorkspaceMode::Split, "Split"); + ui.selectable_value(&mut self.workspace, WorkspaceMode::Canvas, "Canvas"); + ui.separator(); + self.prefs.draw_toolbar(ui, ctx); + if ui.button("Figure fonts").clicked() { + self.prefs.show_font_lab = !self.prefs.show_font_lab; + } + 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"); + } + if self.preview_stale() { + ui.colored_label(egui::Color32::YELLOW, "PDF stale"); + } + ui.label(&self.status); + }); }); - }); + } let split_layout = SplitPanelLayout::for_width(ctx.available_rect().width()); - if self.workspace == WorkspaceMode::Split { + if self.prefs.studio_mode { + show_studio_status_panel(ctx, &self.status, self.tex.xelatex.is_some()); + } + if self.prefs.studio_mode && self.prefs.studio_inspector_open { + let studio = studio_ui::StudioLayout::for_width(ctx.available_rect().width()); + egui::SidePanel::right("b42-studio-inspector") + .resizable(true) + .default_width(studio.inspector_width) + .min_width(225.0) + .max_width(studio.inspector_max) + .show(ctx, |ui| { + if self.pending_open.is_some() { + ui.disable(); + } + self.show_studio_inspector(ui); + }); + } + if !self.prefs.studio_mode && self.workspace == WorkspaceMode::Split { show_split_inspector_panel(ctx, split_layout, |ui| { let previous_revision = self.authoring.source_revision(); if self.pending_open.is_some() { @@ -1582,7 +2030,9 @@ impl eframe::App for TikzariumApp { .show(ui, |ui| self.show_authoring_inspector(ui)); }); } - if self.show_log { + if (!self.prefs.studio_mode && self.show_log) + || (self.prefs.studio_mode && self.prefs.studio_console_open) + { egui::TopBottomPanel::bottom("log") .resizable(true) .default_height(145.0) @@ -1657,11 +2107,36 @@ impl eframe::App for TikzariumApp { }); }); } - if self.workspace == WorkspaceMode::Split { - show_split_source_panel(ctx, split_layout, |ui| { - self.show_source_editor(ui); - }); + if self.prefs.studio_mode + && self.workspace == WorkspaceMode::Split + && self.prefs.studio_tool_rail + { + egui::SidePanel::left("b42-tool-groups") + .resizable(false) + .exact_width( + studio_ui::StudioLayout::for_width(ctx.available_rect().width()) + .tool_rail_width, + ) + .show(ctx, |ui| { + if self.pending_open.is_some() { + ui.disable(); + } + if let Some(anchor) = studio_ui::show_group_rail( + ui, + &mut self.studio_group, + studio_ui::StudioLayout::for_width(ctx.available_rect().width()).narrow, + ) { + self.studio_palette_anchor = anchor; + } + }); } + let actual_source_rect = if self.workspace == WorkspaceMode::Split { + Some(show_split_source_panel(ctx, split_layout, |ui| { + self.show_source_editor(ui); + })) + } else { + None + }; egui::CentralPanel::default().show(ctx, |ui| { if self.pending_open.is_some() { ui.disable(); @@ -1672,6 +2147,29 @@ impl eframe::App for TikzariumApp { self.show_source_editor(ui); } }); + if self.prefs.studio_mode + && self.prefs.studio_tool_rail + && self.pending_open.is_none() + && let Some(source_rect) = actual_source_rect + { + let mut open_libraries = false; + studio_ui::show_palette( + ctx, + &mut self.studio_group, + studio_ui::palette_anchor_at_source_edge(source_rect, self.studio_palette_anchor), + &mut self.authoring, + studio_ui::PaletteData { + source: &self.document.source, + extensions: &self.package_statuses, + filter: &mut self.studio_extension_filter, + }, + &mut open_libraries, + ); + if open_libraries { + self.prefs.studio_dock = StudioDock::Libraries; + self.prefs.studio_inspector_open = true; + } + } if let Some(path) = self.pending_open.clone() { let previous_destination = self.pending_open_save_path.clone(); let decision = show_pending_open_dialog( @@ -1724,7 +2222,7 @@ impl eframe::App for TikzariumApp { fn main() -> eframe::Result<()> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size(egui::vec2(1250.0, 800.0)) + .with_inner_size(egui::vec2(1366.0, 768.0)) .with_min_inner_size(egui::vec2(850.0, 560.0)), ..Default::default() }; @@ -2428,6 +2926,176 @@ mod split_layout_tests { } } + #[test] + fn b42_status_is_full_width_and_does_not_occlude_editable_source_or_guide() { + // The production status panel must be allocated BEFORE any inspector, + // source SidePanel or CentralPanel, at minimum and normal viewport. + // RawInput layout evidence only, not a physical OS-window screenshot. + for (width, height) in [(850.0, 560.0), (1366.0, 768.0)] { + let ctx = egui::Context::default(); + let mut source = QUICKSTART.to_owned(); + let original = source.clone(); + let mut session = tikz_authoring::Session::default(); + session.reset(&source); + let mut status_rect = egui::Rect::NOTHING; + let mut source_rect = egui::Rect::NOTHING; + let mut guide_rect = egui::Rect::NOTHING; + let _ = ctx.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(width, height), + )), + ..Default::default() + }, + |ctx| { + egui::TopBottomPanel::top("b42-status-test-toolbar").show(ctx, |ui| { + ui.label("Studio: source + native TikZ guide"); + }); + status_rect = show_studio_status_panel(ctx, "Сохраните рисунок", false); + egui::SidePanel::right("b42-status-test-inspector") + .exact_width(225.0) + .show(ctx, |ui| { + ui.label("Русские пояснения C"); + }); + egui::SidePanel::left("b42-status-test-tools") + .exact_width(62.0) + .show(ctx, |ui| { + ui.label("TikZ"); + }); + source_rect = show_split_source_panel( + ctx, + SplitPanelLayout::for_width(ctx.available_rect().width()), + |ui| { + ui.add(egui::TextEdit::multiline(&mut source).code_editor()); + }, + ); + guide_rect = egui::CentralPanel::default() + .show(ctx, |ui| { + tikz_canvas::show_guide_studio( + ui, + &mut session, + &mut source, + tikz_canvas::Capabilities { + installed: true, + loaded: Evidence::Confirmed, + engine_compatible: true, + implemented: true, + }, + ); + }) + .response + .rect; + }, + ); + assert!(status_rect.is_positive()); + assert!(source_rect.is_positive()); + assert!(guide_rect.is_positive()); + assert!( + status_rect.width() >= width - 2.0, + "status row not full width at {width}: {status_rect:?}" + ); + assert!( + source_rect.bottom() <= status_rect.top() + 1.0, + "source hidden under status at {width}" + ); + assert!( + guide_rect.bottom() <= status_rect.top() + 1.0, + "guide hidden under status at {width}" + ); + assert_eq!(source, original); + assert!(!session.undo_available()); + } + } + + #[test] + fn b42_studio_save_icon_event_persists_default_utf8_scratch_without_overwriting() { + // The exact production icon handler is driven by egui pointer frames. + // This is UNIT_EVENT, not an actual X11/Win10/macOS desktop run. + fn frame( + ctx: &egui::Context, + document: &mut Document, + destination: &Path, + rect: &mut egui::Rect, + events: Vec, + ) -> bool { + let mut saved = false; + let _ = ctx.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(850.0, 560.0), + )), + events, + ..Default::default() + }, + |ctx| { + egui::TopBottomPanel::top("b42-save-icon-event").show(ctx, |ui| { + ui.horizontal(|ui| { + let response = studio_ui::icon_button( + ui, + StudioIcon::Save, + false, + 34.0, + "Сохранить исходник · Ctrl+S", + ); + *rect = response.rect; + if response.clicked() { + let path = + typed_save_destination(document, destination.to_str().unwrap()) + .unwrap(); + document.save_to(&path).unwrap(); + saved = true; + } + }); + }); + }, + ); + saved + } + + assert_eq!(DEFAULT_SCRATCH_FILENAME, "figure.tex"); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(DEFAULT_SCRATCH_FILENAME); + let original = "% исходный комментарий\r\n\\MyOpaque{Не менять}\r\n"; + let mut document = Document::scratch(original); + let ctx = egui::Context::default(); + let mut rect = egui::Rect::NOTHING; + assert!(!frame(&ctx, &mut document, &path, &mut rect, vec![])); + assert!(rect.is_positive()); + let pos = rect.center(); + let button = |pressed| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + assert!(!frame( + &ctx, + &mut document, + &path, + &mut rect, + vec![egui::Event::PointerMoved(pos), button(true)], + )); + assert!(frame( + &ctx, + &mut document, + &path, + &mut rect, + vec![button(false)], + )); + assert_eq!(std::fs::read(&path).unwrap(), original.as_bytes()); + assert_eq!(Document::load(&path).unwrap().source, original); + assert!(!document.dirty()); + + let another_scratch = Document::scratch("% другой черновик\r\n"); + assert!( + typed_save_destination(&another_scratch, path.to_str().unwrap()).is_err(), + "the suggested scratch name must never overwrite a prior document" + ); + assert_eq!(std::fs::read(&path).unwrap(), original.as_bytes()); + } + #[test] fn b33_typed_save_rejects_existing_open_target_and_accepts_new_utf8_file() { let dir = tempfile::tempdir().unwrap(); @@ -2520,6 +3188,91 @@ mod split_layout_tests { ); } + #[test] + fn b42_dirty_open_scratch_preserves_safe_destination_on_real_egui_save_click() { + // UNIT_EVENT: drive the production Save-and-open button. The File menu + // may have changed its field to the incoming file; never save there. + fn frame( + ctx: &egui::Context, + document: &Document, + target: &Path, + input: &mut String, + events: Vec, + ) -> (Option, egui::Rect) { + let mut result = (None, egui::Rect::NOTHING); + let _ = ctx.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(850.0, 560.0), + )), + events, + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let (action, rect, _) = + pending_open_controls(ui, document, target, input, None); + result = (action, rect); + }); + }, + ); + result + } + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("открыть.tex"); + let safe = dir.path().join("мой-черновик.tex"); + let original = "% чужой CRLF\r\n\\foreignMacro{Не менять}\r\n"; + let scratch_source = "% набросок\r\n\\opaqueUser{оставить}\r\n"; + std::fs::write(&target, original).unwrap(); + let mut scratch = Document::scratch(scratch_source); + assert!(scratch.dirty()); + let mut input = initial_pending_open_save_path(&scratch, &target, &safe); + assert_eq!(input, safe.to_str().unwrap()); + let ctx = egui::Context::default(); + let (_, button) = frame(&ctx, &scratch, &target, &mut input, vec![]); + assert!(button.is_positive()); + let at = button.center(); + let pointer = |pressed| egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + frame( + &ctx, + &scratch, + &target, + &mut input, + vec![egui::Event::PointerMoved(at), pointer(true)], + ); + assert_eq!( + frame(&ctx, &scratch, &target, &mut input, vec![pointer(false)]).0, + Some(PendingOpenAction::Save(safe.clone())) + ); + scratch.save_to(&safe).unwrap(); + assert_eq!(std::fs::read(&safe).unwrap(), scratch_source.as_bytes()); + assert_eq!(Document::load(&target).unwrap().source, original); + assert_eq!(Document::load(&safe).unwrap().source, scratch_source); + assert_eq!( + initial_pending_open_save_path(&Document::scratch(scratch_source), &target, &safe), + "", + "occupied destination must not be suggested again" + ); + assert_eq!( + initial_pending_open_save_path(&Document::scratch(scratch_source), &target, &target), + "", + "incoming target must not be proposed as scratch Save" + ); + let mut loaded = Document::load(&target).unwrap(); + loaded.source.push_str("% ручная правка\r\n"); + assert_eq!( + initial_pending_open_save_path(&loaded, &target, &safe), + target.to_str().unwrap() + ); + } + #[test] fn b32_real_egui_pending_open_click_rejects_target_then_saves_elsewhere() { fn frame( @@ -3126,3 +3879,148 @@ mod split_layout_tests { assert!(!authoring.undo_available()); } } + +#[cfg(test)] +mod b42_file_popup_tests { + use super::*; + + fn frame( + ctx: &egui::Context, + file_field: &mut String, + events: Vec, + modifiers: egui::Modifiers, + ) -> StudioFileMenuResponse { + let mut result = None; + let _ = ctx.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(850.0, 560.0), + )), + events, + modifiers, + ..Default::default() + }, + |ctx| { + egui::TopBottomPanel::top("b42-file-popup-event").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.strong("TikZarium"); + ui.separator(); + result = Some(show_studio_file_menu(ui, file_field, true)); + }); + }); + egui::CentralPanel::default().show(ctx, |_ui| {}); + }, + ); + result.expect("the production File menu was not laid out") + } + + fn pointer(pos: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + } + } + + fn click( + ctx: &egui::Context, + file_field: &mut String, + pos: egui::Pos2, + ) -> (StudioFileMenuResponse, StudioFileMenuResponse) { + frame( + ctx, + file_field, + vec![egui::Event::PointerMoved(pos), pointer(pos, true)], + Default::default(), + ); + let released = frame( + ctx, + file_field, + vec![pointer(pos, false)], + Default::default(), + ); + let settled = frame(ctx, file_field, vec![], Default::default()); + (released, settled) + } + + #[test] + fn b42_file_textedit_click_keeps_popup_open_then_real_open_action() { + // I62 repro: old ui.menu_button closed the popup upon focusing TextEdit. + // UNIT_EVENT through the actual production menu, NOT physical X11. + let directory = tempfile::tempdir().unwrap(); + let second = directory.path().join("second-crlf.tex"); + let untouched = "% Не менять\r\n\\OpaqueSecond{тест}\r\n"; + std::fs::write(&second, untouched).unwrap(); + + let ctx = egui::Context::default(); + let mut field = "figure.tex".to_owned(); + let initial = frame(&ctx, &mut field, vec![], Default::default()); + assert!(initial.trigger_rect.is_positive()); + let (_, opened) = click(&ctx, &mut field, initial.trigger_rect.center()); + assert!(opened.path_rect.is_some(), "File popup did not open"); + + let (_, focused) = click(&ctx, &mut field, opened.path_rect.unwrap().center()); + assert!( + focused.path_rect.is_some(), + "clicking inside File TextEdit must not dismiss the popup" + ); + let control = egui::Modifiers { + ctrl: true, + command: true, + ..Default::default() + }; + frame( + &ctx, + &mut field, + vec![egui::Event::Key { + key: egui::Key::A, + physical_key: None, + pressed: true, + repeat: false, + modifiers: control, + }], + control, + ); + frame( + &ctx, + &mut field, + vec![egui::Event::Key { + key: egui::Key::A, + physical_key: None, + pressed: false, + repeat: false, + modifiers: control, + }], + Default::default(), + ); + let entered = frame( + &ctx, + &mut field, + vec![egui::Event::Text("second-crlf.tex".into())], + Default::default(), + ); + assert_eq!( + field, "second-crlf.tex", + "focused File TextEdit must replace the path" + ); + assert!( + entered.path_rect.is_some(), + "typing must not close the menu" + ); + + let visible = frame(&ctx, &mut field, vec![], Default::default()); + let open_rect = visible + .open_rect + .expect("Open button not visible after editing"); + let (activated, closed) = click(&ctx, &mut field, open_rect.center()); + assert_eq!(activated.action, Some(StudioFileAction::OpenPath)); + assert!( + closed.path_rect.is_none(), + "Open must explicitly close File" + ); + assert_eq!(std::fs::read(&second).unwrap(), untouched.as_bytes()); + assert_eq!(Document::load(&second).unwrap().source, untouched); + } +} diff --git a/crates/tikzarium-app/src/studio_ui.rs b/crates/tikzarium-app/src/studio_ui.rs new file mode 100644 index 00000000..abe8924d --- /dev/null +++ b/crates/tikzarium-app/src/studio_ui.rs @@ -0,0 +1,949 @@ +//! B42: grouped icon palettes for TikZ-native authoring. +//! Tool and parameter semantics belong to the existing C catalogue. Vector +//! icon strokes are only UI affordances, never a second PGF renderer. +use crate::tikz_authoring::{self, Session, Tool}; +use eframe::egui::{self, Pos2, Rect, Sense, Shape, Stroke, Vec2}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum StudioDock { + #[default] + Properties, + Libraries, + Help, +} +impl StudioDock { + pub const ALL: [Self; 3] = [Self::Properties, Self::Libraries, Self::Help]; + pub const fn label(self) -> &'static str { + match self { + Self::Properties => "Свойства", + Self::Libraries => "Библиотеки", + Self::Help => "Справка", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StudioGroup { + Geometry, + Connections, + Text, + Extensions, +} +impl StudioGroup { + pub const ALL: [Self; 4] = [ + Self::Geometry, + Self::Connections, + Self::Text, + Self::Extensions, + ]; + pub const fn label(self) -> &'static str { + match self { + Self::Geometry => "Геометрия", + Self::Connections => "Линии и стрелки", + Self::Text => "Узлы и подписи", + Self::Extensions => "Расширения TikZ", + } + } + pub const fn tools(self) -> &'static [Tool] { + match self { + Self::Geometry => &[Tool::Line, Tool::Rectangle, Tool::Circle, Tool::Cubic], + Self::Connections => &[Tool::Arrow], + Self::Text => &[Tool::Node], + Self::Extensions => &[], + } + } +} +#[derive(Clone, Copy, Debug)] +pub struct StudioLayout { + pub tool_rail_width: f32, + pub inspector_width: f32, + pub inspector_max: f32, + pub narrow: bool, +} +impl StudioLayout { + pub fn for_width(width: f32) -> Self { + let width = if width.is_finite() { + width.max(0.0) + } else { + 0.0 + }; + Self { + tool_rail_width: 62.0, + inspector_width: (width * 0.22).clamp(225.0, 300.0), + inspector_max: (width * 0.28).clamp(260.0, 350.0), + narrow: width < 1050.0, + } + } +} + +#[derive(Clone, Copy)] +pub enum StudioIcon { + Tool(Tool), + Group(StudioGroup), + Open, + Save, + Build, + Export, + Help, + Console, +} +fn line(p: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke) { + p.line_segment([a, b], stroke); +} +fn draw_icon(ui: &egui::Ui, rect: Rect, kind: StudioIcon, selected: bool) { + let p = ui.painter_at(rect); + let c = rect.center(); + let t = rect.width().min(rect.height()) * 0.28; + let stroke = Stroke::new( + 1.9, + if selected { + ui.visuals().selection.stroke.color + } else { + ui.visuals().text_color() + }, + ); + let point = |x: f32, y: f32| Pos2::new(c.x + t * x, c.y + t * y); + match kind { + StudioIcon::Tool(Tool::Line) => { + line(&p, point(-1.0, 0.9), point(1.0, -0.9), stroke); + p.circle_filled(point(-1.0, 0.9), 2.3, stroke.color); + p.circle_filled(point(1.0, -0.9), 2.3, stroke.color); + } + StudioIcon::Tool(Tool::Arrow) => { + line(&p, point(-1.0, 0.0), point(1.0, 0.0), stroke); + line(&p, point(1.0, 0.0), point(0.3, -0.65), stroke); + line(&p, point(1.0, 0.0), point(0.3, 0.65), stroke); + } + StudioIcon::Tool(Tool::Rectangle) => { + let a = point(-1.0, -0.72); + let b = point(1.0, 0.72); + p.add(Shape::closed_line( + vec![a, point(1.0, -0.72), b, point(-1.0, 0.72)], + stroke, + )); + } + StudioIcon::Tool(Tool::Circle) => { + p.circle_stroke(c, t * 0.9, stroke); + } + StudioIcon::Tool(Tool::Node) => { + p.add(Shape::closed_line( + vec![ + point(-1.0, -0.8), + point(1.0, -0.8), + point(1.0, 0.8), + point(-1.0, 0.8), + ], + stroke, + )); + p.text( + c, + egui::Align2::CENTER_CENTER, + "T", + egui::FontId::proportional(t * 1.15), + stroke.color, + ); + } + StudioIcon::Tool(Tool::Cubic) => { + let mut points = Vec::with_capacity(21); + for i in 0..=20 { + let v = i as f32 / 20.0; + let inv = 1.0 - v; + let x = inv * inv * inv * (-1.1) + + 3.0 * inv * inv * v * (-0.45) + + 3.0 * inv * v * v * (0.45) + + v * v * v * (1.1); + let y = inv * inv * inv * (0.8) + + 3.0 * inv * inv * v * (-1.3) + + 3.0 * inv * v * v * (1.3) + + v * v * v * (-0.8); + points.push(point(x, y)); + } + p.add(Shape::line(points, stroke)); + } + StudioIcon::Group(StudioGroup::Geometry) => { + p.circle_stroke(point(-0.45, -0.4), t * 0.60, stroke); + p.add(Shape::closed_line( + vec![ + point(0.10, -0.10), + point(1.0, -0.10), + point(1.0, 0.82), + point(0.10, 0.82), + ], + stroke, + )); + } + StudioIcon::Group(StudioGroup::Connections) => { + draw_icon(ui, rect, StudioIcon::Tool(Tool::Arrow), selected) + } + StudioIcon::Group(StudioGroup::Text) => { + draw_icon(ui, rect, StudioIcon::Tool(Tool::Node), selected) + } + StudioIcon::Group(StudioGroup::Extensions) => { + for (x, y) in [(-0.65, -0.65), (0.65, -0.65), (-0.65, 0.65), (0.65, 0.65)] { + p.circle_filled(point(x, y), 2.6, stroke.color); + } + line(&p, point(-0.65, -0.65), point(0.65, 0.65), stroke); + line(&p, point(-0.65, 0.65), point(0.65, -0.65), stroke); + } + StudioIcon::Open => { + p.add(Shape::line( + vec![ + point(-1.05, -0.45), + point(-0.45, -0.45), + point(-0.18, -0.15), + point(0.95, -0.15), + point(0.85, 0.76), + point(-0.98, 0.76), + point(-1.05, -0.45), + ], + stroke, + )); + } + StudioIcon::Save => { + p.add(Shape::closed_line( + vec![ + point(-0.85, -1.0), + point(0.58, -1.0), + point(0.95, -0.65), + point(0.95, 1.0), + point(-0.85, 1.0), + ], + stroke, + )); + line(&p, point(-0.43, -0.9), point(-0.43, -0.3), stroke); + line(&p, point(-0.43, -0.3), point(0.38, -0.3), stroke); + line(&p, point(-0.43, 0.25), point(0.55, 0.25), stroke); + } + StudioIcon::Build => { + p.add(Shape::closed_line( + vec![point(-0.65, -1.0), point(1.0, 0.0), point(-0.65, 1.0)], + stroke, + )); + } + StudioIcon::Export => { + p.add(Shape::line( + vec![ + point(-0.9, -0.3), + point(-0.9, 0.98), + point(0.9, 0.98), + point(0.9, -0.3), + ], + stroke, + )); + line(&p, point(0.0, -1.0), point(0.0, 0.38), stroke); + line(&p, point(0.0, 0.38), point(-0.5, -0.13), stroke); + line(&p, point(0.0, 0.38), point(0.5, -0.13), stroke); + } + StudioIcon::Help => { + p.circle_stroke(c, t, stroke); + p.text( + c, + egui::Align2::CENTER_CENTER, + "?", + egui::FontId::proportional(t * 1.6), + stroke.color, + ); + } + StudioIcon::Console => { + p.add(Shape::closed_line( + vec![ + point(-1.0, -0.8), + point(1.0, -0.8), + point(1.0, 0.8), + point(-1.0, 0.8), + ], + stroke, + )); + line(&p, point(-0.65, -0.3), point(-0.2, 0.0), stroke); + line(&p, point(-0.2, 0.0), point(-0.65, 0.3), stroke); + line(&p, point(-0.05, 0.35), point(0.65, 0.35), stroke); + } + } +} + +/// Scalable vector icon. A short, stable tooltip includes the native TikZ +/// command and its actual C-owned manual reference. +pub fn icon_button( + ui: &mut egui::Ui, + kind: StudioIcon, + selected: bool, + side: f32, + tooltip: &str, +) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(Vec2::splat(side), Sense::click()); + if ui.is_rect_visible(rect) { + let p = ui.painter_at(rect); + let fill = if selected { + ui.visuals().selection.bg_fill + } else if response.hovered() { + ui.visuals().widgets.hovered.bg_fill + } else { + ui.visuals().widgets.inactive.bg_fill + }; + p.rect_filled(rect, egui::CornerRadius::same(7), fill); + draw_icon(ui, rect, kind, selected); + } + response.on_hover_text(tooltip) +} + +/// F1 opens the same C-backed Help tab; toggling F1 off must visibly leave +/// that tab rather than only changing an invisible Session::help boolean. +pub fn route_studio_help_toggle( + was_enabled: bool, + is_enabled: bool, + dock: &mut StudioDock, + inspector_open: &mut bool, +) { + if !was_enabled && is_enabled { + *dock = StudioDock::Help; + *inspector_open = true; + } else if was_enabled && !is_enabled && *dock == StudioDock::Help { + *dock = StudioDock::Properties; + } +} + +/// The toolbar Help icon is a toggle, just like F1. A hidden inspector +/// with a remembered Help tab must reopen, not close. +pub fn toggle_studio_help_button( + session: &mut Session, + dock: &mut StudioDock, + inspector_open: &mut bool, +) { + if *inspector_open && *dock == StudioDock::Help { + session.help = false; + *dock = StudioDock::Properties; + } else { + session.help = true; + *dock = StudioDock::Help; + *inspector_open = true; + } +} + +/// Narrow permanent rail: four *groups* rather than dozens of buttons. +/// Clicking a group opens a floating palette; the code and PDF keep their size. +pub fn show_group_rail( + ui: &mut egui::Ui, + active: &mut Option, + narrow: bool, +) -> Option { + show_group_rail_observed(ui, active, narrow, |_, _| {}) +} + +// Observe native widget rectangles in event tests without changing production clicks. +fn show_group_rail_observed( + ui: &mut egui::Ui, + active: &mut Option, + narrow: bool, + mut observe: impl FnMut(StudioGroup, Rect), +) -> Option { + let _ = narrow; + ui.vertical_centered(|ui| { + ui.strong("TikZ"); + ui.separator(); + }); + let mut anchor = None; + for group in StudioGroup::ALL { + let tooltip = format!("Палитра: {}. Нажмите для раскрытия.", group.label()); + let response = icon_button( + ui, + StudioIcon::Group(group), + *active == Some(group), + 43.0, + &tooltip, + ); + observe(group, response.rect); + if response.clicked() { + *active = if *active == Some(group) { + None + } else { + Some(group) + }; + anchor = Some(response.rect.right_top()); + } + } + anchor +} + +/// Position the popup from the ACTUAL resizable source SidePanel boundary, +/// never from its default width. No PDF-to-guide coordinate mapping is implied. +/// The floating palette itself adds a small 6pt gap in show_palette. +pub fn palette_anchor_at_source_edge(source_panel: Rect, rail_anchor: Pos2) -> Pos2 { + Pos2::new(source_panel.right() + 8.0, rail_anchor.y) +} + +/// Floating grouped palette. It never takes space from the code/PDF columns. +/// Future package adapters appear as unavailable, not hidden or falsely ready. +/// Borrow the current read-only project facts as one UI context, not a +/// second registry and not a license to mutate user source or install TeX. +pub struct PaletteData<'a> { + pub source: &'a str, + pub extensions: &'a [tikzarium_extensions::ExtensionStatus], + pub filter: &'a mut String, +} + +pub fn show_palette( + ctx: &egui::Context, + group: &mut Option, + anchor: Pos2, + session: &mut Session, + data: PaletteData<'_>, + open_libraries: &mut bool, +) { + show_palette_observed(ctx, group, anchor, session, data, open_libraries, |_, _| {}); +} + +// The observer exposes the very same C tool buttons used by the Studio GUI. +fn show_palette_observed( + ctx: &egui::Context, + group: &mut Option, + anchor: Pos2, + session: &mut Session, + data: PaletteData<'_>, + open_libraries: &mut bool, + mut observe: impl FnMut(Tool, Rect), +) { + let PaletteData { + source, + extensions, + filter: extension_filter, + } = data; + if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { + *group = None; + } + let Some(selected) = *group else { return }; + egui::Area::new(egui::Id::new("b42-group-palette")) + .order(egui::Order::Foreground) + .fixed_pos(anchor + egui::vec2(6.0, -36.0)) + .show(ctx, |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(if selected == StudioGroup::Extensions { + 265.0 + } else { + 158.0 + }); + ui.horizontal(|ui| { + ui.strong(selected.label()); + if ui + .small_button("×") + .on_hover_text("Закрыть палитру") + .clicked() + { + *group = None; + } + }); + ui.separator(); + if selected == StudioGroup::Extensions { + ui.small("Расширения из общего реестра"); + ui.add( + egui::TextEdit::singleline(extension_filter) + .hint_text("Поиск пакета…") + .desired_width(242.0), + ); + let filter = extension_filter.trim().to_lowercase(); + egui::ScrollArea::vertical() + .max_height(270.0) + .show(ui, |ui| { + for item in extensions.iter().filter(|item| { + filter.is_empty() + || item.manifest.id.to_lowercase().contains(&filter) + || item.manifest.display_name.to_lowercase().contains(&filter) + }) { + ui.strong(&item.manifest.display_name); + ui.small(format!( + "Установлен: {:?} · Совместимость: {:?}", + item.installation, item.compilation + )); + ui.small(format!( + "GUI-инструмент: {} · просмотр: {}", + if item.visual_editable { + "есть" + } else { + "пока нет" + }, + if item.inspectable { + "есть" + } else { + "пока нет" + }, + )); + ui.separator(); + } + }); + ui.small("Наличие ≠ подключение ≠ поддержка инструмента. Lua-only не XeLaTeX."); + if ui.button("Управление библиотеками проекта").clicked() + { + *open_libraries = true; + *group = None; + } + } else { + ui.horizontal_wrapped(|ui| { + for &tool in selected.tools() { + let info = tikz_authoring::spec(tool); + let status = tikz_authoring::requirements_status(source, tool); + let tip = format!( + "{}\n{}\n{}\nPGF/TikZ §{}\nПодключение: {:?}", + info.title_ru, + info.help_ru, + info.syntax, + info.manual_section, + status + ); + let response = icon_button( + ui, + StudioIcon::Tool(tool), + session.active == Some(tool), + 46.0, + &tip, + ); + observe(tool, response.rect); + if response.clicked() { + session.select_tool(tool); + *group = None; + } + } + }); + ui.small("Наведите на иконку для команды и справки."); + if selected == StudioGroup::Connections { + ui.small("Стрелки arrows.meta требуют явного подключения."); + } + } + }); + }); +} + +/// The same read-only C catalogue consumed by the inspector and F1. +pub fn show_context_help(ui: &mut egui::Ui, session: &Session) { + ui.heading("Справка TikZ"); + if let Some(tool) = session.active { + let info = tikz_authoring::spec(tool); + ui.strong(&info.title_ru); + ui.label(&info.help_ru); + ui.code(&info.syntax); + ui.label("Пример:"); + ui.code(&info.example); + ui.hyperlink_to( + format!("PGF/TikZ §{}", info.manual_section), + &info.manual_url, + ); + ui.small(format!( + "Пакет: {} · Библиотеки: {} · PGF {}", + info.requirements.packages.join(", "), + info.requirements.libraries.join(", "), + info.requirements.documented_against + )); + for param in &info.parameter { + egui::CollapsingHeader::new(¶m.id).show(ui, |ui| { + ui.label(¶m.help_ru); + ui.code(¶m.syntax); + ui.small(format!( + "Тип: {} · Единицы: {} · Допустимые значения: {}", + param.kind, + param.units.join(", "), + param.allowed_values.join(", ") + )); + if param.default_provenance == "editor_preset" { + ui.small(format!( + "Предустановка редактора: {} (НЕ default TikZ)", + param.default + )); + } else { + ui.small("Default TikZ: UNKNOWN"); + } + ui.label(¶m.warning_ru); + ui.label(¶m.interaction_ru); + ui.code(¶m.example); + ui.hyperlink_to( + format!("PGF/TikZ §{}", param.manual_section), + ¶m.manual_url, + ); + }); + } + } else { + ui.label("Выберите инструмент в левой палитре."); + ui.small("Подсказки и параметры поступают из единого каталога C."); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn compact_group_rail_is_bounded_with_expansion_as_floating_overlay() { + for width in [850.0_f32, 1180.0, 1366.0, 1920.0] { + let layout = StudioLayout::for_width(width); + assert_eq!(layout.tool_rail_width, 62.0); + assert!(width - layout.tool_rail_width - layout.inspector_width - 240.0 > 190.0); + } + } + #[test] + fn groups_cover_all_native_c_tools_once_and_expose_future_adapters() { + let all = StudioGroup::ALL + .iter() + .flat_map(|g| g.tools()) + .copied() + .collect::>(); + assert_eq!(all.len(), 6); + for item in crate::tikz_authoring::TOOLS { + assert_eq!(all.iter().filter(|&&t| t == item).count(), 1); + assert!( + tikz_authoring::spec(item) + .manual_url + .starts_with("https://tikz.dev/") + ); + } + assert!(StudioGroup::Extensions.tools().is_empty()); + } + #[test] + fn icon_tooltips_and_dock_types_do_not_duplicate_document_state() { + let v = serde_json::to_string(&StudioDock::Libraries).unwrap(); + assert_eq!( + serde_json::from_str::(&v).unwrap(), + StudioDock::Libraries + ); + assert_eq!(StudioDock::ALL.len(), 3); + } + #[test] + fn real_f1_key_frames_toggle_visible_studio_dock_without_editing_tex() { + let ctx = egui::Context::default(); + let source = "% ручной CRLF\r\n\\myOpaqueMacro{unknown}\r\n".to_owned(); + let before = source.clone(); + let mut session = Session::default(); + session.reset(&source); + let mut dock = StudioDock::Properties; + let mut open = false; + let frame = + |event: egui::Event, session: &mut Session, dock: &mut StudioDock, open: &mut bool| { + let _ = ctx.run( + egui::RawInput { + events: vec![event], + ..Default::default() + }, + |ctx| { + let old = session.help; + crate::tikz_canvas::handle_help_shortcut(ctx, session); + route_studio_help_toggle(old, session.help, dock, open); + egui::CentralPanel::default().show(ctx, |ui| { + ui.label("Исходник редактируем, PDF — внешний XeLaTeX"); + }); + }, + ); + }; + let key = |pressed: bool| egui::Event::Key { + key: egui::Key::F1, + physical_key: None, + pressed, + repeat: false, + modifiers: egui::Modifiers::default(), + }; + frame(key(true), &mut session, &mut dock, &mut open); + assert!(session.help); + assert_eq!(dock, StudioDock::Help); + assert!(open); + frame(key(false), &mut session, &mut dock, &mut open); + frame(key(true), &mut session, &mut dock, &mut open); + assert!(!session.help); + assert_eq!(dock, StudioDock::Properties); + assert_eq!(source, before); + assert!(!session.undo_available()); + } + + #[test] + fn b42_real_help_toolbar_pointer_and_f1_toggle_the_visible_dock() { + // UNIT_EVENT on the same production icon Response and F1 route. + fn frame( + ctx: &egui::Context, + session: &mut Session, + dock: &mut StudioDock, + open: &mut bool, + events: Vec, + ) -> Rect { + let mut rect = Rect::NOTHING; + let _ = ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, egui::vec2(850.0, 560.0))), + events, + ..Default::default() + }, + |ctx| { + let old = session.help; + crate::tikz_canvas::handle_help_shortcut(ctx, session); + route_studio_help_toggle(old, session.help, dock, open); + egui::TopBottomPanel::top("b42-help-click").show(ctx, |ui| { + let response = icon_button( + ui, + StudioIcon::Help, + *dock == StudioDock::Help && *open, + 32.0, + "Контекстная справка F1 из общего TikZ-каталога", + ); + rect = response.rect; + if response.clicked() { + toggle_studio_help_button(session, dock, open); + } + }); + egui::CentralPanel::default().show(ctx, |ui| { + ui.label("Редактируемый TeX; точный PDF строит XeLaTeX"); + }); + }, + ); + rect + } + + let ctx = egui::Context::default(); + let source = "% русский CRLF\r\n\\opaqueMacro{не переписывать}\r\n"; + let mut session = Session::default(); + session.reset(source); + let mut dock = StudioDock::Properties; + let mut open = false; + let rect = frame(&ctx, &mut session, &mut dock, &mut open, vec![]); + assert!(rect.is_positive()); + let at = rect.center(); + let pointer = |pressed| egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + for visible in [true, false] { + frame( + &ctx, + &mut session, + &mut dock, + &mut open, + vec![egui::Event::PointerMoved(at), pointer(true)], + ); + frame( + &ctx, + &mut session, + &mut dock, + &mut open, + vec![pointer(false)], + ); + assert_eq!(session.help, visible); + assert_eq!( + dock, + if visible { + StudioDock::Help + } else { + StudioDock::Properties + } + ); + assert!(open); + } + dock = StudioDock::Help; + open = false; + frame( + &ctx, + &mut session, + &mut dock, + &mut open, + vec![egui::Event::PointerMoved(at), pointer(true)], + ); + frame( + &ctx, + &mut session, + &mut dock, + &mut open, + vec![pointer(false)], + ); + assert!(session.help && open && dock == StudioDock::Help); + + let key = |pressed| egui::Event::Key { + key: egui::Key::F1, + physical_key: None, + pressed, + repeat: false, + modifiers: Default::default(), + }; + frame(&ctx, &mut session, &mut dock, &mut open, vec![key(true)]); + assert!(!session.help); + assert_eq!(dock, StudioDock::Properties); + frame(&ctx, &mut session, &mut dock, &mut open, vec![key(false)]); + frame(&ctx, &mut session, &mut dock, &mut open, vec![key(true)]); + assert!(session.help); + assert_eq!(dock, StudioDock::Help); + assert_eq!( + source, + "% русский CRLF\r\n\\opaqueMacro{не переписывать}\r\n" + ); + assert!(!session.undo_available()); + } + + #[test] + fn palette_follows_actual_source_pane_resize_not_default_width() { + // Simulated measured response rectangles from a resizable source panel. + let button = Pos2::new(61.0, 178.0); + let narrow = Rect::from_min_size(Pos2::new(62.0, 60.0), Vec2::new(240.0, 600.0)); + let wide = Rect::from_min_size(Pos2::new(62.0, 60.0), Vec2::new(475.0, 600.0)); + let a = palette_anchor_at_source_edge(narrow, button); + let b = palette_anchor_at_source_edge(wide, button); + assert_eq!(a.x, narrow.right() + 8.0); + assert_eq!(b.x, wide.right() + 8.0); + assert_eq!(b.x - a.x, 235.0); + assert_eq!(a.y, button.y); + assert_eq!(b.y, button.y); + } + + #[test] + fn icon_geometry_does_not_change_source_in_egui_frame() { + let ctx = egui::Context::default(); + let mut source = String::from("% opaque CRLF\r\n\\newcommand{\\mine}{value}\r\n"); + let before = source.clone(); + let _ = ctx.run(egui::RawInput::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + icon_button(ui, StudioIcon::Tool(Tool::Cubic), false, 44.0, "Bezier"); + icon_button( + ui, + StudioIcon::Group(StudioGroup::Extensions), + false, + 44.0, + "Packages", + ); + }); + }); + assert_eq!(source, before); + source.clear(); + } + + #[test] + fn b42_rawinput_group_and_six_native_tool_icons_use_production_handlers() { + // Actual egui press/release; layout positions are observed from + // production widgets, not guessed or supplied as alternate controls. + // UNIT_EVENT only: does not replace real Linux/X11 desktop acceptance. + struct State { + group: Option, + anchor: Pos2, + groups: [Rect; 4], + tools: [Rect; 6], + session: Session, + filter: String, + libraries: bool, + } + impl State { + fn frame(&mut self, ctx: &egui::Context, source: &str, events: Vec) { + let State { + group, + anchor, + groups, + tools, + session, + filter, + libraries, + } = self; + let _ = ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(850.0, 560.0))), + events, + ..Default::default() + }, + |ctx| { + egui::SidePanel::left("b42-real-event-rail") + .resizable(false) + .exact_width(62.0) + .show(ctx, |ui| { + if let Some(next) = + show_group_rail_observed(ui, group, true, |kind, rect| { + let index = StudioGroup::ALL + .iter() + .position(|&candidate| candidate == kind) + .unwrap(); + groups[index] = rect; + }) + { + *anchor = next; + } + }); + egui::CentralPanel::default().show(ctx, |ui| { + ui.label("TikZ source is authoritative, not provisional overlay."); + }); + show_palette_observed( + ctx, + group, + *anchor, + session, + PaletteData { + source, + extensions: &[], + filter, + }, + libraries, + |tool, rect| { + let index = crate::tikz_authoring::TOOLS + .iter() + .position(|&candidate| candidate == tool) + .unwrap(); + tools[index] = rect; + }, + ); + }, + ); + } + fn click(&mut self, ctx: &egui::Context, source: &str, pos: Pos2) { + for pressed in [true, false] { + self.frame( + ctx, + source, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }, + ], + ); + } + } + } + + let ctx = egui::Context::default(); + let source = "% русский комментарий\r\n\\myOpaque{Не менять}\r\n".to_owned(); + let untouched = source.clone(); + let mut state = State { + group: None, + anchor: Pos2::ZERO, + groups: [Rect::NOTHING; 4], + tools: [Rect::NOTHING; 6], + session: Session::default(), + filter: String::new(), + libraries: false, + }; + state.session.reset(&source); + for kind in [ + StudioGroup::Geometry, + StudioGroup::Connections, + StudioGroup::Text, + ] { + for &tool in kind.tools() { + state.frame(&ctx, &source, vec![]); + let group_index = StudioGroup::ALL + .iter() + .position(|&candidate| candidate == kind) + .unwrap(); + assert!(state.groups[group_index].is_positive()); + state.click(&ctx, &source, state.groups[group_index].center()); + assert_eq!(state.group, Some(kind), "{kind:?} group not opened"); + // egui Areas register their popup hit-test widgets on the + // following frame. Settle that frame before dispatching the + // next primary press into a newly opened foreground palette. + state.frame(&ctx, &source, vec![]); + let tool_index = crate::tikz_authoring::TOOLS + .iter() + .position(|&candidate| candidate == tool) + .unwrap(); + assert!( + state.tools[tool_index].is_positive(), + "{tool:?} button absent" + ); + state.click(&ctx, &source, state.tools[tool_index].center()); + assert_eq!(state.session.active, Some(tool), "{tool:?} not selected"); + assert_eq!(state.group, None, "palette remains open after tool click"); + assert_eq!(source, untouched, "click cannot rewrite opaque TeX"); + assert!(!state.session.undo_available(), "selection is not a patch"); + } + } + assert!(!state.libraries); + } +} diff --git a/crates/tikzarium-app/src/tikz_canvas.rs b/crates/tikzarium-app/src/tikz_canvas.rs index 9129e2d6..f9bbcbfc 100644 --- a/crates/tikzarium-app/src/tikz_canvas.rs +++ b/crates/tikzarium-app/src/tikz_canvas.rs @@ -227,35 +227,63 @@ fn hit_authored_anchor(source: &str, rect: Rect, pos: Pos2) -> Result, +) -> (Option, Rect) { + show_guide_frame_mode(ui, session, source, caps, palette_rects, true) +} + +/// Both workspaces use EXACTLY the same gesture processing and source patching. +/// Studio moves the existing C-owned palette into a persistent tool rail; it +/// does not replace the native TikZ drawing handler or TeX with a Rust solver. +fn show_guide_frame_mode( ui: &mut egui::Ui, session: &mut Session, source: &mut String, caps: Capabilities, mut palette_rects: Option<&mut [Rect; 6]>, + legacy_palette: bool, ) -> (Option, Rect) { - ui.horizontal_wrapped(|ui| { - ui.strong("TikZ · инструменты"); - for (index, tool) in TOOLS.into_iter().enumerate() { - let info = tikz_authoring::spec(tool); - let button = ui - .add(egui::Button::new(&info.title_ru).selected(session.active == Some(tool))) - .on_hover_text(format!( - "{}\n{}\nPGF/TikZ §{}\n{}", - info.help_ru, info.syntax, info.manual_section, info.manual_url - )); - if let Some(rects) = palette_rects.as_deref_mut() { - rects[index] = button.rect; + if legacy_palette { + ui.horizontal_wrapped(|ui| { + ui.strong("TikZ · инструменты"); + for (index, tool) in TOOLS.into_iter().enumerate() { + let info = tikz_authoring::spec(tool); + let button = ui + .add(egui::Button::new(&info.title_ru).selected(session.active == Some(tool))) + .on_hover_text(format!( + "{}\n{}\nPGF/TikZ §{}\n{}", + info.help_ru, info.syntax, info.manual_section, info.manual_url + )); + if let Some(rects) = palette_rects.as_deref_mut() { + rects[index] = button.rect; + } + if button.clicked() { + session.select_tool(tool); + } } - if button.clicked() { - session.select_tool(tool); + }); + ui.strong(match session.active { + Some(tool) => format!("ВЫБРАН: {}", tikz_authoring::spec(tool).title_ru), + None => "ИНСТРУМЕНТ НЕ ВЫБРАН".to_owned(), + }); + ui.small("ПОЛЕ ВВОДА · шаг 0.25 cm. Esc отменяет клик/жест до отпускания мыши. Alt+клик на #ID выбирает команду. Это не PDF; стили и преобразования выполняет TeX."); + } else { + ui.horizontal(|ui| { + ui.strong("Эскиз TikZ"); + if let Some(tool) = session.active { + ui.small(format!( + "Выбран: {} · шаг 0,25 cm", + tikz_authoring::spec(tool).title_ru + )); + } else { + ui.small("Выберите инструмент в палитре слева."); } - } - }); - ui.strong(match session.active { - Some(tool) => format!("ВЫБРАН: {}", tikz_authoring::spec(tool).title_ru), - None => "ИНСТРУМЕНТ НЕ ВЫБРАН".to_owned(), - }); - ui.small("ПОЛЕ ВВОДА · шаг 0.25 cm. Esc отменяет клик/жест до отпускания мыши. Alt+клик на #ID выбирает команду. Это не PDF; стили и преобразования выполняет TeX."); + }); + } let project_state = session .active .map(|tool| tikz_authoring::requirements_status(source, tool)); @@ -263,7 +291,9 @@ fn show_guide_frame_with_palette( && project_state == Some(tikzarium_extensions::primitives::Evidence::Confirmed); // Missing XeLaTeX/tikz.sty is a build-time warning, not a veto on safe // source patches. Keep import UNKNOWN/ABSENT fail-closed independently. - if !ready || !caps.exact_build_prerequisites_verified() { + if (legacy_palette || session.active.is_some()) + && (!ready || !caps.exact_build_prerequisites_verified()) + { ui.colored_label( ui.visuals().warn_fg_color, if !caps.source_authoring_supported() || ready { @@ -279,8 +309,13 @@ fn show_guide_frame_with_palette( }, ); } + let height = if legacy_palette { + HEIGHT + } else { + (ui.available_height() - 36.0).clamp(200.0, 700.0) + }; let (rect, response) = ui.allocate_exact_size( - Vec2::new(ui.available_width().max(180.0), HEIGHT), + Vec2::new(ui.available_width().max(180.0), height), Sense::click_and_drag(), ); let painter = ui.painter_at(rect); @@ -562,6 +597,17 @@ pub fn show_guide( show_guide_frame(ui, session, source, caps).0 } +/// B42 studio guide: real egui pointer gestures and revision-checked C source +/// patches are preserved; only the duplicate row of tool buttons is removed. +pub fn show_guide_studio( + ui: &mut egui::Ui, + session: &mut Session, + source: &mut String, + caps: Capabilities, +) -> Option { + show_guide_frame_mode(ui, session, source, caps, None, false).0 +} + pub fn handle_help_shortcut(ctx: &egui::Context, session: &mut Session) { if ctx.input(|input| input.key_pressed(egui::Key::F1)) { session.help = !session.help; @@ -825,13 +871,15 @@ pub fn show_inspector( let mut jump = None; ui.heading("TikZ · параметры и справка"); ui.small("Один каталог: палитра → свойства → команда → F1. Неизвестный TeX не переписывается."); - ui.small(format!( - "Установлен tikz.sty={} · явно подключён={} · внешний XeLaTeX={} · B-инструмент=реализован · C-справка=доступна", - if caps.installed { "CONFIRMED" } else { "UNKNOWN" }, - evidence_label(caps.loaded), - if caps.engine_compatible { "CONFIRMED" } else { "UNKNOWN" }, - )); - ui.small(caps.explanation()); + ui.collapsing("Техническая готовность TikZ / XeLaTeX", |ui| { + ui.small(format!( + "Установлен tikz.sty={} · явно подключён={} · внешний XeLaTeX={} · GUI-инструмент=реализован · справка=доступна", + if caps.installed { "CONFIRMED" } else { "UNKNOWN" }, + evidence_label(caps.loaded), + if caps.engine_compatible { "CONFIRMED" } else { "UNKNOWN" }, + )); + ui.small(caps.explanation()); + }); let choices = tikz_authoring::owned(source); egui::ComboBox::from_id_salt("n1-owned-command") .selected_text( @@ -2077,6 +2125,114 @@ mod tests { } } + // UNIT_EVENT: exercise the Studio guide, not the legacy tool-row guide. + // Group-palette selection is tested separately: Studio deliberately shares + // this production pointer handler and the C-owned source patch path. + fn b42_studio_event_frame( + ctx: &egui::Context, + session: &mut Session, + source: &mut String, + events: Vec, + viewport_width: f32, + ) -> (Option, Rect) { + let mut observed = None; + let mut guide_rect = Rect::NOTHING; + let _ = ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size( + Pos2::ZERO, + Vec2::new(viewport_width, 640.0), + )), + events, + ..Default::default() + }, + |ctx| { + handle_help_shortcut(ctx, session); + egui::CentralPanel::default().show(ctx, |ui| { + (observed, guide_rect) = + show_guide_frame_mode(ui, session, source, READY, None, false); + }); + }, + ); + (observed, guide_rect) + } + + #[test] + fn b42_studio_six_pointer_gestures_reopen_and_undo_use_actual_egui_frames() { + for width in [850.0, 1366.0] { + for tool in TOOLS { + let ctx = egui::Context::default(); + let mut source = EVENT_SOURCE.to_owned(); + let before = source.clone(); + let mut session = Session::default(); + session.reset(&source); + session.select_tool(tool); + assert_eq!(source, before, "{tool:?}: selection changed source"); + let (_, guide) = + b42_studio_event_frame(&ctx, &mut session, &mut source, vec![], width); + assert!(guide.width() > 190.0 && guide.height() > 190.0); + let start = screen_point(guide, Point::new(1.0, 1.0)); + let end = screen_point(guide, Point::new(3.0, 2.0)); + b42_studio_event_frame( + &ctx, + &mut session, + &mut source, + vec![egui::Event::PointerMoved(start), primary(start, true)], + width, + ); + b42_studio_event_frame( + &ctx, + &mut session, + &mut source, + vec![egui::Event::PointerMoved(end)], + width, + ); + let (line, _) = b42_studio_event_frame( + &ctx, + &mut session, + &mut source, + vec![primary(end, false)], + width, + ); + let line = line.expect("Studio pointer release must focus authored command"); + let owned = tikz_authoring::owned(&source); + assert_eq!(owned.len(), 1, "{tool:?}: missing or duplicate C command"); + assert_eq!(owned[0].tool, tool); + assert_eq!(owned[0].line, line); + assert!(source.contains(&tikz_authoring::command(tool, &owned[0].draft).unwrap())); + assert!(source.contains(r"\myOpaqueMacro{a_b}")); + assert!(source.contains("% пользовательский непрозрачный материал")); + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("studio.tex"); + std::fs::write(&file, &source).unwrap(); + let saved = std::fs::read_to_string(&file).unwrap(); + assert_eq!(saved, source); + let mut reopened = Session::default(); + reopened.reset(&saved); + assert_eq!(reopened.choose(&saved, 1).unwrap(), line); + let after_drawing = source.clone(); + let help_before = session.help; + b42_studio_event_frame( + &ctx, + &mut session, + &mut source, + vec![egui::Event::Key { + key: egui::Key::F1, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::default(), + }], + width, + ); + assert_ne!(session.help, help_before, "{tool:?}: Studio F1 failed"); + assert_eq!(source, after_drawing, "{tool:?}: F1 touched source"); + session.undo(&mut source).unwrap(); + assert_eq!(source, before, "{tool:?}: Studio Undo changed user TeX"); + } + } + } + #[test] fn actual_egui_pointer_frames_create_six_c_catalogue_commands_and_preserve_source() { for tool in TOOLS { diff --git a/crates/tikzarium-app/src/ui_theme.rs b/crates/tikzarium-app/src/ui_theme.rs index b74641de..0ed5301f 100644 --- a/crates/tikzarium-app/src/ui_theme.rs +++ b/crates/tikzarium-app/src/ui_theme.rs @@ -1,4 +1,5 @@ //! Persistent egui appearance. This styling NEVER changes figure source or PDF colors. +use crate::studio_ui::StudioDock; use eframe::egui::{self, Color32, Stroke}; use serde::{Deserialize, Serialize}; use tikzarium_core::FigureFonts; @@ -8,8 +9,8 @@ pub const STORAGE_KEY: &str = "tikzarium.preferences.v1"; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum UiTheme { Light, - #[default] Dark, + #[default] Midnight, Paper, } @@ -64,6 +65,15 @@ impl UiTheme { egui::vec2(8.0, 6.0) }; style.spacing.interact_size.y = if compact { 23.0 } else { 29.0 }; + style + .text_styles + .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0)); + style + .text_styles + .insert(egui::TextStyle::Monospace, egui::FontId::monospace(14.0)); + style + .text_styles + .insert(egui::TextStyle::Small, egui::FontId::proportional(12.0)); }); } } @@ -71,6 +81,11 @@ impl UiTheme { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct UiPreferences { + pub studio_mode: bool, + pub studio_tool_rail: bool, + pub studio_inspector_open: bool, + pub studio_console_open: bool, + pub studio_dock: StudioDock, pub theme: UiTheme, pub compact: bool, pub show_font_lab: bool, @@ -83,8 +98,13 @@ impl Default for UiPreferences { fn default() -> Self { let fonts = FigureFonts::default(); Self { + studio_mode: true, + studio_tool_rail: true, + studio_inspector_open: true, + studio_console_open: false, + studio_dock: StudioDock::default(), theme: UiTheme::default(), - compact: true, + compact: false, show_font_lab: false, text_family: fonts.text_family, sans_family: fonts.sans_family, @@ -136,12 +156,15 @@ mod tests { #[test] fn preferences_default_and_legacy_partial_data() { let defaults = UiPreferences::default(); - assert!(defaults.compact); + assert!(!defaults.compact); + assert!(defaults.studio_mode); + assert_eq!(defaults.studio_dock, StudioDock::Properties); assert_eq!(defaults.math_font, "latinmodern-math.otf"); let old = r#"{"theme":"Paper"}"#; let restored: UiPreferences = serde_json::from_str(old).unwrap(); assert_eq!(restored.theme, UiTheme::Paper); - assert!(restored.compact); + assert!(!restored.compact); + assert!(restored.studio_mode); } #[test] diff --git a/docs/STUDIO_UX_WINDOWS_QA.md b/docs/STUDIO_UX_WINDOWS_QA.md new file mode 100644 index 00000000..2fbe31ce --- /dev/null +++ b/docs/STUDIO_UX_WINDOWS_QA.md @@ -0,0 +1,81 @@ +# B42 — Studio UX and user's Windows Server QA + +## Owner feedback: icon groups, not walls of text buttons + +TikZarium is **not** Photoshop/Corel and does not implement PGF's renderer. +The user rejected its current dense egui toolbar and supplied prior UI design +mockups. This B42 implementation responds with: + +- Four **vector icon GROUPS** in a permanent 62pt left rail, one floating + palette at a time. Geometry contains line/rectangle/circle/Bezier; + connections contains arrow; annotation contains node. Extensions show + PGFPlots/CircuitikZ/tikz-cd as explicitly **not-yet-implemented** GUI + adapters, without hiding source-only package capabilities. +- Commands remain native C-catalogue TikZ generation with exactly the same + revision-checked Undo/SourceTarget and actual egui pointer handlers. + Icons are UI-only strokes, NOT a second geometry engine. +- An unobstructed editable TeX pane beside drawing/PDF, concise vector icon + Open/Save/Build/Export, grouped File/View, a right dock with separate + Properties/Libraries/Help tabs, persistent Light/Dark/Midnight/Paper and + hidden-by-default diagnostics. Figure fonts remain independent. +- Popover tooltips and F1 use the ONE Russian catalogue, not duplicated + descriptions or invented TikZ defaults. +- The old interface is temporarily retained under View for comparison. + +## User Windows Server 2025 with independently installed MiKTeX + +GitHub-hosted Windows Server 2022 ≠ user's persistent Windows Server 2025; +neither equals physical Windows 10 x64. The user says MiKTeX is installed, +but we have **not verified interactive access to the server or any self-hosted +runner registered with TikZarium**. + +Added `scripts/tools/qa_windows_server_user.ps1`: selects only external +MiKTeX, checks identity and `--disable-installer`, records read-only +package inventory before and after, compiles a **copy** of Cyrillic TeX, +compares source hash, archives real PDF, log, optional Poppler evidence and +optionally `cargo test --locked` with one build job. It installs/updates +nothing, never alters MiKTeX AutoInstall settings, and no engine fallback is +permitted. Its JSON distinguishes full font verification from PARTIAL. + +Manual GitHub workflow `qa-selfhosted-winserver.yml` requires a separately +registered non-admin runner with exact label `tikzarium-winserver`. It is +`workflow_dispatch` **only on main**, and refuses a mismatched expected +SHA. No pull_request-triggered persistent runner. Do not share GitHub runner +registration tokens in chat or put secrets in the repo. Ensure the public +repository cannot run arbitrary untrusted code on the self-hosted runner. +Restrict machine permissions and avoid using an Administrator runner. + +Recommended setup on the server, in PowerShell: + +```powershell +Get-Service | Where-Object Name -Like 'actions.runner*' +Get-Command xelatex,miktex,cargo,pdffonts,pdfinfo -ErrorAction SilentlyContinue | + Select-Object Name,Source +xelatex --disable-installer --version | Select-Object -First 2 +``` + +If a runner is currently attached to a different GitHub repository, do +not overwrite it. Set up another dedicated runner under GitHub +TikZarium → Settings → Actions → Runners → New self-hosted runner, and label +it `tikzarium-winserver`. Then after an accepted main merge, manually launch +the new workflow with exact main SHA. + +Running GitHub Actions as Windows *service* is Session 0 and **does not +constitute GUI acceptance**. A separate interactive RDP/VNC desktop must +test real window pointer/keyboard, Save/Reopen, F1, actual PDF viewport and +screen DPI. Mark this **NOT_RUN** until actual screenshots/logs exist. + +## Acceptance checkpoints + +1. Exact PR SHA: cargo fmt/test/clippy, hosted Linux/Windows/macOS CI. +2. New studio Linux/X11 desktop proof at 1366x768 and 850x560, screenshot + review; all 6 native tools, group picker, C help, source, Undo, libraries, + and externally produced PDF. Old coordinate-based B41 X11 runs do not + automatically count, and must fail closed if the app tree changes. +3. Self-hosted Windows Server MiKTeX code/fixture PDF test, real artifact. +4. Separate physical Win10 x64 and macOS M1/M4 acceptance (NOT_RUN). +5. No announcement of release from a concept render or a hosted server pass. + +Current status on authoring: B42 source written; exact-head CI NOT_RUN, +new real B42 GUI NOT_RUN, user Windows Server runner NOT_CONNECTED/NOT_RUN, +physical Windows 10 and Mac M1/M4 NOT_RUN. diff --git a/insights/2026-11-02_B42_UX_EVIDENCE.md b/insights/2026-11-02_B42_UX_EVIDENCE.md new file mode 100644 index 00000000..d145b264 --- /dev/null +++ b/insights/2026-11-02_B42_UX_EVIDENCE.md @@ -0,0 +1,197 @@ +# B42 forensic UX review and QA honesty + +Observed B41 real X11 images in UI album: +- long text Open/Save/Build/Export/Discovery menus occupy two rows; +- six full-word tool buttons occupy another row above provisional guide; +- combined Help + Library + Inspector + Package inventory is a long + inaccessible scroll; bottom log uses 145px even without an error; +- compact Fit Page had to shrink PDF to 7%. + +Selected revised architecture: only 4 group icons remain in rail; grouped +floating palettes avoid permanently taking PDF/source width. 4 command +icons on top; File/View only as secondary menus; context right dock tabs. +Existing source-patch and external compiler contracts unchanged. + +QA status at authoring: source code created; no local Rust binary +in integrated container, NOT_RUN local; CI on exact PR HEAD pending; +new real egui X11 NOT_RUN until artifacts reviewed. User-installed MiKTeX +reported by owner but GitHub runner connectivity NOT_VERIFIED; +persistent Windows Server test NOT_RUN; physical Win10/M1/M4 NOT_RUN. + +Risk: old coordinate-pinned desktop drivers must be recalibrated for +new layout, not marked PASS from historical B41. Keep existing CI gates. + +## First real B42 Linux/X11 screenshots reviewed + +Actual executable/bootstrap [run 41412320640](https://github.com/i-shutov/tikzarium/actions/runs/41412320640), artifact 13126535781, was downloaded and the two real PNGs independently inspected. Group Geometry popup **really opens** with four vector icons; no generated TikZ or XeLaTeX claim. Found remaining UX problems: Light initial palette too pale, small text, popup overlapping the editable source, and an unnecessary orange warning before choosing a tool. + +This B42 correction changes default to Midnight, increases Body/Monospace font sizes, moves popup over the center workspace, omits empty-tool warning, and presents package entries from the actual C extension Registry with independent installed/engine/tool states rather than hard-coded three-name buttons. Focusing the PDF no longer resets manual zoom on every subsequent frame. Requires new exact-head Rust and GUI screenshots before acceptance. The old preview screenshot status stays historical, not a new PASS. + +B42 first Rust/D QA compiled and passed unit tests on Ubuntu, but strict Clippy found unused imports. These were removed in the current B42 head. All updated CI + actual final GUI **NOT_RUN** until complete. The Windows Server runner and physical Win10/M1/M4 remain NOT_RUN. + +## Studio-mode six-primitive event-frame evidence (B42 QA extension) + +An audit found that existing complete six-gesture `RawInput` tests called +the **legacy** guide, while B42 added a Studio branch removing its tool row. +Added `b42_studio_six_pointer_gestures_reopen_and_undo_use_actual_egui_frames` +in the same B42 PR: 2 viewport widths × 6 native C tools, actual egui pointer +press/move/release, focus line, C-produced command, file Save/Reopen, +Russian/opaque source preservation, F1 and byte-exact visual Undo. No +catalogue regeneration and no new solver. Existing legacy tests remain intact. + +Authoring checkpoint: branch commit `ea1d8cf14264ba1e83eb31ff37230d6ba14eeadd`. +**No claim of test PASS until exact-head hosted CI completes**; no local +Rust toolchain or physical Win10/M1/M4 runtime available in this environment. +A real Studio grouped-palette click and real X11 source Ctrl+Z/Save/Open +with independent screenshots remain a separate gate. + +## Resizable source/palette defect and correction + +Static code audit of B42 identified a reproducible mismatch: +`show_split_source_panel` has `resizable(true)`, yet the palette X +position was `studio_palette_anchor.x + split_layout.source_default + 18`. +The default width is not the current source panel width after a resize. +Likewise, turning off the tool rail did not prevent `show_palette` from +continuing to render the old selected group. + +Changed only B-owned `main.rs`/`studio_ui.rs`: use the measured egui +source SidePanel response rectangle, hide/close the palette with the rail, +and regression-check measured-boundary arithmetic for both 240pt/475pt +source widths. App code commit `2909e4684c8743462ccf205adf51c1ee94ac0c8b`; +helper/test commit `312ffd86d648ae0a99937b18735cf7d8de064304`. +Full GUI manual resize verification: **NOT_RUN** until exact-head X11 +artifact and screenshots are reviewed. Physical Win10/M1/M4: **NOT_RUN**. + +## F1 help-tab state mismatch + +Source inspection: Studio `update` reacted only to F1 false→true, switching +`prefs.studio_dock=Help`; a subsequent F1 true→false left the tab at Help +although `Session::help` was false. This would render the second shortcut +visually inert. Fixed in B-owned Studio UI and main via shared +`route_studio_help_toggle`, with real egui RawInput F1 press/release/press +test asserting Help→Properties and no source/Undo mutations. Commits +`01b626edaa607e05c25d309725fc29c171590b93` and +`8faa2989bf038fd1d6679d37338c168664668746`. +Exact-head CI and independent real X11 F1 screenshot remain separate gates; +do not report PASS before evidence is available. + +## B42 exact-head CI failure repaired and real Studio screenshots independently inspected + +On source SHA `88877297726b0d788108e995b4dbec3e4bb22d92`, strict Rust +formatting failed on every Rust matrix at `studio_ui.rs:571`; tests were +**not executed** past that gate. The exact rustfmt layout from the hosted +job log was applied in commit +`f2273814aa160be5c0ec7b8779f9e4b7831ab6d7`, on the SAME #91 branch. +Do not inherit earlier green tests; rerun all exact-head checks. + +Downloaded and inspected **two** real Ubuntu/X11 screenshots from the B42 +bootstrap [run 41421044602](https://github.com/i-shutov/tikzarium/actions/runs/41421044602) +on that formatted app tree. ZIP CRC passes and image SHA-256 matches the +artifact manifest: workspace +`b5f717856087c397b9a87a98965f581a180e6f055256724072a240f413538cad`; +Geometry popup +`d3be3595ba89bed353a8af1a7c11a81c173ac4140722c2e1edc213e92a1e3c77`. +The screenshot does visibly show the popup with four selectable geometric +icons outside the editable source pane. This only proves an actual +*group click*, not selection of an individual native tool, six gestures, +source manual Ctrl+Z, file Open/Save, or XeLaTeX PDF in the Studio process. +The artifact's originally stored manual-review field remains historically +NOT_RUN; this independent review is recorded here without falsifying it. + +Three older I51/I57 workflows on the formatted HEAD refuse the B42 tree +**before GUI execution**; those failures cannot count as gesture FAIL/PASS. +D-owned independent coordinate re-leasing remains mandatory. Physical +Windows 10 and M1/M4 remain NOT_RUN. + +## Persistent Windows Server PDF QA trust correction + +The proposed self-hosted audit incorrectly accepted +`pdffonts | -match '\byes\b'`, which only checked that *some* font had +`yes`. The B-owned script now parses every font record, rejects any +nonembedded row, requires DejaVu Serif in the Cyrillic fixture, checks +one page and extracts the expected Russian text with `pdftotext`. +Absent tools produce partial/NOT_RUN rather than false PASS. Optional +`pdftoppm` exports a PNG explicitly marked unreviewed, not proof of +visible glyph fidelity. Implemented in +`f56b3e12c48ab095c859671b74a6698c71072c84`. +This script is NOT_RUN on the actual dedicated Windows Server; hosted +MiKTeX proof does not substitute for it. Exact-head CI, script runtime, +and visual glyph review are independent gates. + +## B42 external D I61 failed at first Save: screenshot-to-code root cause + +[run 41427351166](https://github.com/i-shutov/tikzarium/actions/runs/41427351166) +on #92 exact D SHA `517906e80fab2bda3c6aafa834a7b90a066790df` +returned `gui_save=NOT_RUN`, `six_real_tools=NOT_RUN`, +`real_xelatex_pdf=NOT_RUN`; error: `Save did not create default +figure.tex`. Independently downloaded GitHub artifact `13133090660`, +verified the screenshot bytes against its report and visually examined +`02-file-menu-default-path.png` and `04-source-pasted.png`. The +visible `figure.tex` was grey *placeholder*, not a value. Source +`TikzariumApp::new` set `file_field` empty, while Linux +`typed_save_destination` refuses empty input. This establishes +a real first-save workflow defect, not a gesture or XeLaTeX regression. + +Changed B main.rs only for production: the new scratch filename +`figure.tex` is prefilled as actual editable text, and existing +non-overwrite protection remains. Added actual egui event-frame +Save-icon test with CRLF/Cyrillic macro Save/Reopen and occupied +destination rejection. Exact-head hosted CI and new independent +D X11 acceptance remain PENDING; no physical OS claims. + + +## B42 actual group/tool selection event-frame QA — 2026-11-02 + +Identified an evidence gap, not a confirmed new production UX bug: +the previously added six-gesture Studio test directly sets Session::active, +and X11 bootstrap proves group opening only. The new B-owned UNIT_EVENT +uses production rail/palette click handlers, egui Response rectangles and +pointer press/release for all six C-catalogue native tool icons. Selection +closes the palette without editing CRLF, Cyrillic text, unknown macros or Undo. + +Exact-head Rust test and independent X11 acceptance are pending after +this patch. Never equate the event test with physical Win10/M1/M4. + + +## B42 full-width status bar layout order — 2026-11-02 + +Reproducible source-level UX defect: eframe::App::update previously called +TopBottomPanel::bottom("b42-status") *after* CentralPanel and the side panels. +Egui layout is order-dependent; late bottom allocation can obscure content +and cannot reserve the intended full viewport width. The production call is +now before inspector, tool rail, source, central and optional diagnostics, +with the same status text and XeLaTeX prerequisite indication. + +B-owned UNIT_EVENT uses the same status handler and actual egui panel layout +at 850x560 / 1366x768 to assert full width, nonoverlapping source/guide, +unchanged TeX and Undo. No invented PDF coordinate authority. Hosted exact-head +CI and independent real X11 remain separate and pending at authoring. + + +## B42 Help icon and dirty-Open continuity QA (2026-11-02) + +Source audit isolated two concrete B-owned UX defects: + +1. In Studio, the Help toolbar handler always forced Help on, while + F1 already toggled the dock. Second icon click was visually inert. + A single B transition now toggles to Properties; when the inspector + is hidden with Help remembered, clicking reopens Help. The selected + icon now reflects actual inspector visibility. +2. Dirty Open initialized Save-and-open only from document.path. + For a dirty scratch this was empty despite B42 having a real + figure.tex scratch Save default. The new initial suggestion uses + the existing typed-save and open-target guards, never the incoming + File-menu target. Occupied, aliased or target candidates fail closed. + +Added direct egui RawInput click tests on production Help icon and modal +Save button, F1 interoperability, CRLF/Russian/unknown macro preservation +and no false visual Undo. These tests do not mean true desktop GUI +acceptance. D #92 Studio X11 six gestures, manual source Ctrl+Z, +two-file Save/Open and exact XeLaTeX PDF remain gates; physical +Windows10/M1/M4 and the owner's Windows Server remain NOT_RUN. + +## B42 P0 File-popup forensic correction and fix (2026-11-02) + +I62 run 41470355844 artifact 13154818704: root coordinate (440,93) IS inside File path TextEdit (screenshot 33). Screenshot 34 shows collapsed menu before path entry, screenshot 35 is byte-identical. Prior incorrect-coordinate hypothesis was retracted. egui 0.33.3 PopupCloseBehavior default is CloseOnClick, including clicks inside menu: production ui.menu_button File was unsuitable for an embedded TextEdit. Source-level repro is confirmed; a physical corrected executable has NOT_RUN. + +Fix only the B-owned File menu to CloseOnClickOutside and keep explicit closing for activated Open/Save As. The new event-frame test of the real extracted handler asserts that focusing does not dismiss the popup, Ctrl+A and typing edit the path, Open closes explicitly, and a separate CRLF/Russian/opaque fixture is untouched. Against the old default click-to-close semantics the first keep-open assertion would fail; no measured pre-fix red-run is claimed. No changes to catalogues, compiler, themes or D QA. New exact-head CI and independent I62 full X11 acceptance are required before merge; prior six primitive → source → XeLaTeX PDF proof remains historical evidence. diff --git a/insights/2026-11-02_I61_B42_STUDIO_REAL_X11_QA.md b/insights/2026-11-02_I61_B42_STUDIO_REAL_X11_QA.md new file mode 100644 index 00000000..13fdb1eb --- /dev/null +++ b/insights/2026-11-02_I61_B42_STUDIO_REAL_X11_QA.md @@ -0,0 +1,65 @@ +# I61 B42 independent actual Studio acceptance — 2026-11-02 + +Main `93db2044b5b91f79c0e774be8806a3693ba7329b`; B42 `dadd05b16c0c917f28a57f233b12931fbbcb98cd` (snapshot). The previous I51/I57 source-tree profiles still target B41 and fail **before launching egui** on B42, so neither genuine PASS nor actual functionality FAIL can be inferred. The B42 bootstrap proves a real window and opening the *Geometry group popup*, **not** a selected command or PDF. + +This QA commit adds a **separate full Studio X11 acceptance candidate**, source/PDF SHA ledger, all-six tool icon and pointer sequence and external XeLaTeX PDF predicates; no production code changes. It pins B42's exact app/src tree and captures screenshots and files on early failures for subsequent coordinate repair. Strictly no offscreen Session call masquerades as a real GUI interaction. + +**Evidence at authoring:** code ready for hosted action; execution, artifact checks and manual screenshot interpretation **NOT_RUN**. Even a green automated run needs manual visual review and does not cover all Undo/Open/CRLF gates or physical OS acceptance. Do not merge this QA or B42 on this statement alone. + +## First I61 actual-X11 run — failed, root cause and repair + +Exact previous QA HEAD `0ca4f3c8a69b67c026b5ed142af2b4242e266e07`, real [PR workflow 41425235038](https://github.com/i-shutov/tikzarium/actions/runs/41425235038), original artifact **13131797362** independently downloaded and inspected. Strict exact app-tree lease PASSED; 24.04 hosted TeXLive/X11 prerequisites and locked production Rust build PASSED; actual Studio process started and produced screenshot `01-real-studio.png`. `02-file-menu.png` was **byte-identical** to the first image (SHA-256 `faada4760c84d33c85af0a15b8cac0ea3192bcb001a400a672e3b9bf7d95ccc2`) instead of showing the menu. Driver then blocked for 25 seconds in `xclip`, never reached a real GUI Save or TikZ pointer. Report correctly FAIL / remaining NOT_RUN. **No product source defect established.** + +Independent analysis identified two QA-driver defects: X11 commands used `xdotool mousemove --window` with coordinates measured from the **root screenshot**, thereby adding WM titlebar y offset; and `run()` captured pipes of the intentionally backgrounded xclip owner, keeping `subprocess.run` open. Fix uses root pixel coordinates for click/gesture and noncapturing xclip consistent with previous I47 desktop QA. Also fixes a tautological source-hash comparison at PDF export. New exact-head run and manual visual acceptance remain NOT_RUN until CI completes. + +## Second I61 run — real source input proved, File path click recalibrated + +Exact QA HEAD `5a46488d6d201d91cc1879ae71f74580fed932bd` [run 41425938749](https://github.com/i-shutov/tikzarium/actions/runs/41425938749), original artifact **13131254633** independently downloaded and screenshots inspected. The previous client/root mismatch and xclip timeout are resolved: the **real Studio File menu opens** (screenshot `02-file-menu.png` differs from startup; field is visibly at x307..575 y83..103), and original user TeX with Russian `Не менять` and opaque `\\UserLabel` is **visibly pasted into the live editable source pane** (`04-source-pasted.png`). However `03-path-in-file-menu.png` equals the closed startup image: click at y101 hit the input's lower border and dismissed the menu; with no valid path, real Studio Save refused to create a file. This is a QA coordinate error, not proof of a product Save defect. No native tools/PDF touched, report correctly FAIL and NOT_RUN. Correct input click to center (420,93) on the next QA HEAD, add strict all-font per-row embedding predicate to the later PDF stage. Execution/artifact review for corrected HEAD NOT_RUN at commit. + +## Third actual-X11 artifact review — custom File-path edit remains unresolved + +Exact head `5f4c6c59e6dec776551aea32a32360fd5f8ddb97`, [run 41426644654](https://github.com/i-shutov/tikzarium/actions/runs/41426644654), original artifact **13132690797** downloaded. Real menu and manually pasted Unicode/opaque source are visually present, but candidate popup path click still dismissed the menu before input; GUI Save did not write a file. This is NOT a demonstrated product Save defect. Further coordinate nudges would obscure the main native-authoring vertical. + +Revised independent driver starts the actual egui process with **isolated cwd=out**, relies on actual GUI File-menu default `figure.tex` shown in screenshot, pastes original .tex via X11 and uses actual toolbar Save to create the file. Then six grouped-icon TikZ gestures and external XeLaTeX PDF can be evaluated without fabricating input/output or touching product code. Custom-path menu editing explicitly stays NOT_RUN. Duplicate pull_request action trigger removed to avoid exact-commit run cancellation by the subsequent push event; QA branch push only. New exact-head CI NOT_RUN at commit. + + +### Integration re-lease to frozen B42 (2026-11-02 UTC) + +Production HEAD #91 is `91e2d8201cd0c016e00d52618e4024fedb66424e`, app/src tree `64df229392c03a99ca29238bcc2e5f422c4d7409`; independent I61 QA remains a stacked PR targeting the B branch. Latest B42 bootstrap [41445054318](https://github.com/i-shutov/tikzarium/actions/runs/41445054318) reports two PNG hashes `4367ce5d798befaf2a3a157089db23df4cc5540b2d83814d94bacb15bb9f5766` and `df7cdc32f345f4988bad8de5029ababd396fb5f720f80d464945cb90f06ee1df`, identical to already manually reviewed B42 layout. Thus button coordinates are **candidate only**, not a gesture PASS. New font parser checks ALL `pdffonts` embedding flags from the right with four positive/negative offline tests, not an exit-code proxy. + +I61 still requires actual original GUI saved-source, six pointer gestures, help, XeLaTeX PDF and independent artifact SHA/manual review. Older I51/I57 B41-tree FAILs occur before GUI and are stale gates; no smoke or synthetic event-frame is a substitute. Physical Win10 MiKTeX/Mac ARM remain NOT_RUN. + + +## CI ownership migration — 2026-11-02 + +B42 is frozen at `91e2d8201cd0c016e00d52618e4024fedb66424e`, app/src tree `64df229392c03a99ca29238bcc2e5f422c4d7409`. Current independent real-X11 six-tool → GUI saved native-source → external XeLaTeX PDF evidence: [I61 run 41449047973](https://github.com/i-shutov/tikzarium/actions/runs/41449047973), original artifact 13143756910, 40/40 SHA-256 rechecked. I61 now provides B42 six-tool regression on PR/main and push/main; if a future app/src tree changes, the exact source-tree and coordinate lease must be re-reviewed rather than silently passing stale pointer positions. + +Four historical B41 fixed-tree GUI workflows (`n1-desktop-smoke` I51, `i46-b34-dirty-open` I51, `i53-b38-live-libraries` I53, and `i57-n2-four-live` I57) no longer run automatically against `main`. They retain explicit workflow_dispatch and their historical branch triggers, including the original B41 tree check: **actual tests were not removed**. Their B42 red runs failed on hardcoded tree `d290bdad...` **before any GUI**, not on a real B42 interaction. I61 does **not** substitute for their Open/Undo/library assertions. + +**Still blocked, NOT_RUN at B42 real GUI level:** two-file Open/dirty Save-and-open with CRLF and opaque macro, source/native and visual Undo after manual changes, inspector pinned Apply, deterministic Help/F1 (I61 frames 20/21 ambiguous), connected libraries; physical Win10/MiKTeX and Mac ARM XeLaTeX. These are required release gates, not waived by a green six-gesture smoke. Merge #92 into frozen #91, then #91 into main only after full independent B42 evidence and exact-head CI with reviewed original artifacts; run separate post-merge CI. + + +## I62 correction: exact-head CI cancellation and real GUI I/O acceptance expansion + +The new QA head f3aa1ea7 run 41458975300 job 125322826120 completed with GitHub `cancelled` during apt package downloads, not a Rust/TeX/product test FAIL. GitHub log: 76 packages/129MB via azure.archive.ubuntu.com, texlive-base 21.7MB from 14:18 to 14:36 UTC and texlive-pictures 16.7MB from 14:40 until cancellation at 14:53 UTC. Exact app-tree check and offline font-parser tests passed, but app compilation, real gestures and PDF were SKIPPED. The previously accepted cc339308 I61 run remains valid for its exact head only. New apt URL preference and HTTP inactivity/retry settings aim to avoid repeated 45-minute waste. + +New QA-only probes target true Linux/X11 F1 two-state tab, native source undo, session visual Undo, two-file CRLF Open/Edit/Save/Reopen and dirty Open cancellation. All are NOT_RUN pending exact-head CI and original evidence review; any QA focus/coordinate miss must be investigated, not promoted to product FAIL or synthetic PASS. No B production/registry changes. Inspector Apply, dirty Save-and-open and physical targets stay NOT_RUN. + + +## I63 evidence boundary / integration lease — B42 File TextEdit popup repair, 2026-11-02 + +Independent integrator re-leased I61 on frozen B42 #91 `09a4df5baf0f7446bb02c68fbb126821d2a0ff9f`, verified `crates/tikzarium-app/src` Git tree `72499c6e31d4cf6a2a36d16fb7bc8b10a9d66acb` by GitHub Git tree API. Old B head `91e2d820…` and app tree `64df229392c03a99ca29238bcc2e5f422c4d7409` are historical; the QA workflow now pins the new tree and rejects any further B production drift. B only changed its File popup behavior (egui 0.33.3 CloseOnClickOutside) plus B plans/insights, preserving the C generator and all six tools. Exact B head hosted Rust CI, multi-OS D QA, generated-source XeLaTeX, hosted MiKTeX, font audits all passed; B test is `UNIT_EVENT`, not a successful real two-file X11 Open. + +One stacked integration commit combines B's three-file change with the existing #92 QA-only workflow re-lease, this plan/insights and CHANGELOG. Do not rerun old B41 hard-coded SHA guards and do not create new QA micro-PRs. New D exact-head real Linux/X11 must prove File TextEdit focus→input→real Open of independent CRLF/opaque second file, edit/save/reopen, dirty Cancel and preserve six native gestures, F1/Undo, Unicode embedded-font XeLaTeX PDF. Inspector pinned Apply remains a separate explicit real GUI release gate unless this execution proves it. Manual source/PDF/screenshot/hash audit mandatory before either merge. Physical Win10/MiKTeX, M1/M4 XeLaTeX NOT_RUN. + + +## I63 QA double-lease correction — 2026-11-02 + +The integrator caught an additional historical B app-tree SHA hardcoded in the Python real-X11 driver (not only the workflow), which would reject the correct B42 executable before GUI. Both now bind exact `72499c6e31d4cf6a2a36d16fb7bc8b10a9d66acb`; lightweight CI preflight asserts equality BEFORE installing TeX Live. Added screenshots immediately after File TextEdit pointer focus and Ctrl+A to distinguish focus from clipboard paste if two-file Open fails. Same stacked QA #92 only, no B edits/new PR. Prior first I63 head is superseded on this exact issue; any CI failure from that old head is stale QA rather than demonstrated product regression. Complete Open/Undo/Apply and physical Win10/M1/M4 remain unaccepted until evidence. + + +## I64 exact B42 pinned Inspector Apply complete-vertical gate + +I63 [real Linux/X11 41505873672](https://github.com/i-shutov/tikzarium/actions/runs/41505873672) completed SUCCESS on exact D QA HEAD `74b67c1b77a7071b2ea4ca6eae4e321e6a28f5d2` and frozen B app/source tree `72499c6e31d4cf6a2a36d16fb7bc8b10a9d66acb`: GUI File path TextEdit stayed open, loaded independent CRLF/opaque second .tex, GUI edit/save/reload, dirty Open cancellation, six native gestures, F1/Undo and external XeLaTeX PDF. Independent original artifact 13170508997 verified 64/64 SHA-256, full File/Open/dirty modal/F1 PNGs and PDF glyphs/font embedding visually reviewed. This is a concrete accepted hosted Linux/X11 subvertical, not physical Win10/M1/M4. + +To finish the remaining N1 gap on the same frozen B production tree, existing I61 script now drives actual selected Node #6 `x` TextEdit with a different `10.50cm` value, asserts draft-only Save leaves source bytes unchanged, real pinned `Применить параметры` writes exactly one C-owned Node command/metadata source line, and real inspector visual Undo restores the saved file byte-exactly. New original screenshots and SHA ledger required, no direct Rust Session fixture or `UNIT_EVENT` accepted as replacement. New exact-head CI and independent artefact manual review are PENDING. No B production edits or additional PR/workflows; physical Win10/MiKTeX and Mac ARM XeLaTeX still NOT_RUN. diff --git a/plans/2026-11-02_B42_STUDIO_UX_WINDOWS_SERVER.md b/plans/2026-11-02_B42_STUDIO_UX_WINDOWS_SERVER.md new file mode 100644 index 00000000..bf29a28d --- /dev/null +++ b/plans/2026-11-02_B42_STUDIO_UX_WINDOWS_SERVER.md @@ -0,0 +1,193 @@ +# B42 — Large ergonomic studio block and Windows Server test bridge + +Source of truth: i-shutov/tikzarium main +93db2044b5b91f79c0e774be8806a3693ba7329b; +AGENTS, current source and user's previous interface mockups. + +Owner rejects many text buttons and poor compact-window readability. +Deliver native vector icons grouped into popover palettes, short action +toolbar, persistent code beside drawing/PDF, independently scrolling +context tabs and on-demand diagnostics. Keep original TikZ and XeLaTeX. + +Tools grouped: geometry 4, connections 1, annotation 1, extensions +explicit NOT_IMPLEMENTED for adapters. No duplicate C catalogue. +Undo/Save/Open and CRLF remain untouched. Legacy GUI temporary fallback. + +Windows Server stream: safe manual workflow with own user-installed MiKTeX, +no hidden installer, actual PDF inventory/hash, cargo --locked; self-hosted +runner is not known connected yet. Windows Server != Windows10. +B42 exact-head hosted CI + real X11 screenshots and user-server external +test are release gates, never infer them from source changes alone. + +## UX revision after actual X11 bootstrap + +- Default theme Midnight, retained 4 persistent UI themes. +- Monospace text 14pt, body 14pt, small 12pt for readable code/help. +- Popover over canvas rather than over source (actual screenshot showed overlap). +- Extension popup is data-driven from C Registry (no fixed top-three list); search and 270pt scroll cap for growth. +- Eliminate duplicate first-load orange error with no selected tool. +- PDF focus changes only on selection transition; preserve manual zoom. +- Re-run real X11 screenshot and all original source/Undo/TeX checks on revised exact HEAD before merging. + +## B42 UNIT_EVENT Studio gesture regression (additional B-owned coverage) + +A direct `egui::Context::run(RawInput)` test now drives the **Studio** guide +(`show_guide_frame_mode(..., legacy_palette=false)`) at 850 and 1366 pt. +For each of the six C-owned primitives it sends actual pointer down/move/up, +asserts exactly one authored C command and the selected source line, then +checks on-disk Save/Reopen, F1 without a source mutation, and byte-exact visual +Undo preserving an opaque macro and Russian comment. + +This protects against the B42 UI refactor inadvertently bypassing the +production pointer handler. Palette group icon selection, the full desktop +layout, source native Ctrl+Z and external XeLaTeX still require independent +real X11 evidence. The test is **UNIT_EVENT**, not Linux/X11 process QA and +not physical Windows 10 or macOS ARM acceptance. + +## B42 resized-source palette placement and hidden-rail consistency + +Reproducible code-path UX defect: `main.rs` positioned the floating +tool palette using `SplitPanelLayout::source_default`, while the editable +source SidePanel is `resizable(true)`. After the user changed its width, +the palette no longer started at the actual source/guide boundary and could +obscure the code or float unexpectedly. The View menu could also hide the +tool rail without closing an already open floating palette. + +The production fix now reads the real `SidePanel::show(...).response.rect`, +anchors the palette at `source_rect.right()+8pt`, gates the popup on the +visible rail and closes its open group when the rail is disabled. A separate +test covers both wide and narrow actual-boundary input rectangles; the +B42 real-RawInput six-gesture test covers code patches independently. + +Acceptance still requires a real X11 resize/rail hide/reopen screenshot +sequence plus six live Studio gestures, genuine source Ctrl+Z and PDF. +Synthetic rectangles and `UNIT_EVENT` do not prove real resizable GUI. + +## B42 F1 Studio dock coherence + +In B42, the first F1 switches to the shared C Help tab, but the second F1 +previously only cleared `Session::help` while leaving the Help tab visually +unchanged. Keep the control honest: F1 on opens the inspector's Help tab; +F1 off returns to Properties when Help is selected. The existing B41 F1 +fallback for no active tool still applies. Reuse one C catalogue. +Cover real `egui::Context::run(RawInput)` F1 down/up/down with persisted +opaque TeX and no Undo entries. Independent actual desktop screenshots +must still verify visible tab movement, not just the unit event. + +## Reacceptance on B42 exact-head after CI and QA corrections + +The early B42 exact HEAD `88877297726b0d788108e995b4dbec3e4bb22d92` +failed `cargo fmt --all -- --check` on a single event-frame closure. +The fix `f2273814aa160be5c0ec7b8779f9e4b7831ab6d7` is production +semantics-neutral. Do not merge based on the earlier short-circuited tests; +require new successful hosted Rust/test/Clippy and D QA on the final SHA. + +The old B41/B37 X11 drivers have source-tree guards; D owns new Studio +calibration, six actual tool selections/gestures, inspector/Undo, F1, +manual source Ctrl+Z, two CRLF/opaque-macro Open/Save files, and PDF raster. +B bootstrap confirmed only the Geometry group popup on real X11. + +User-server audit must distinguish all embedded fonts, extracted expected +Cyrillic text, and visually reviewed rendered glyphs as separate facts. +The prior any-`yes` predicate was unsafe. The corrected B-owned +`scripts/tools/qa_windows_server_user.ps1` does not auto-install packages, +and produces partial states if Poppler PDF tools are unavailable. The +dedicated Windows Server and physical Windows 10/M1/M4 remain NOT_RUN. + +## B42 real-X11 scratch Save incident and B-owned correction (2026-11-02) + +Independent stacked D #92 I61 Linux/X11 on B42 source tree +`817ef8606d882ed39111b886fce57e0c0a37fb3a`, run +[41427351166](https://github.com/i-shutov/tikzarium/actions/runs/41427351166), +artifact `13133090660` stopped **after a real clipboard paste** with +`Studio toolbar Save did not create default figure.tex`. The original +`02-file-menu-default-path.png` shows that `figure.tex` was only a +TextEdit grey *hint*, whereas B-owned `TikzariumApp::new` initialized +`file_field = String::new()`. On Linux `save_as()` uses the actual +field via `typed_save_destination` and thus rejects an empty path. +This is a concrete Studio UX defect (and a wrong D assumption), **not** +a failure of the six native pointer tools: I61 never got that far. + +B42 now gives the new scratch file a **real editable** `figure.tex` +destination, matching the already displayed hint. Explicit user Save +writes the current directory only if that name does not exist; existing +files are still rejected by `typed_save_destination` (no overwrite). +File → Save As retains user-controlled paths. A new B-owned +`RawInput` UNIT_EVENT test clicks the *production vector Save icon*, +checks UTF-8/CRLF/opaque macro byte fidelity on disk and fails closed +for a second scratch with an occupied destination. + +New B-only commit HEAD exact SHA/CI: inspect after push; do not +report I61 PASS until D independently re-leases its app tree, +reruns all six real X11 icons/gestures, Save/Reopen, Help/Undo and +XeLaTeX source/PDF + manual artifact review. This UNIT_EVENT is +not physical Win10/MiKTeX, Mac M1/M4 or the user Windows Server. + + +## B42 grouped icon selection UNIT_EVENT extension — 2026-11-02 + +Gap found: existing six-gesture Studio tests inject an active tool, while +real Linux/X11 bootstrap only proved opening the Geometry group. The B +egui regression now clicks real group and all six tool Response handlers +via press/release RawInput frames at 850×560. Actual egui response rectangles +are observed; production publicly exposed signatures remain unchanged. +The native C registry still supplies tools, syntax and generator. + +The selected tool, popup dismissal, and no source/Undo modification are +asserted. This is UNIT_EVENT, not a physical desktop test, not PDF validation. +D's independent I61 real X11, manual source Ctrl+Z, two-file Open/Save, and +external XeLaTeX remain necessary. Await exact-head CI results. + + +## B42 bottom-status panel structural layout repair — 2026-11-02 + +Code audit identified a concrete egui panel ordering issue: Studio rendered +TopBottomPanel::bottom("b42-status") AFTER CentralPanel had consumed the +viewport. That makes bottom-row reservation order-dependent and risks +overlapping/cropping the live guide or source. Also bottom status rendered +after side panels, so it did not reserve the full window width. + +Move the real status panel handler before all Studio side and central panels +(and before optional diagnostics) so it owns the full-width bottom row and +diagnostics sit above it. Add a real egui RawInput layout regression with +the production status handler, editor TextEdit and native guide at 850x560 +and 1366x768. Assert full width, status non-overlap, source byte integrity +and no spurious Undo. UNIT_EVENT, not physical Win10/M1/M4 or real X11. +Exact-head Rust/QA and independent desktop screenshots are acceptance gates. + + +## B42 Help toggle and dirty-Open scratch continuity (2026-11-02) + +Two B42 UX inconsistencies were reproduced directly in the production +event paths. The toolbar Help icon unconditionally set Help=true; a +second click on a selected Help icon did not leave the Help tab, unlike +the already supported F1 toggle. The B-owned toolbar handler now changes +the same visible Properties/Help dock, including reopening a hidden +inspector with a remembered Help tab. A real egui Context::run(RawInput) +press/release test covers two icon clicks, hidden dock, subsequent F1 +and preservation of Cyrillic/CRLF/opaque TeX and visual Undo. + +After the earlier first-Save figure.tex fix, dirty Open of an unsaved +scratch still initialized its independent Save-and-open path as empty. +The File menu path may already refer to the incoming document and must +not be reused. A B helper suggests the scratch filename only after +the pre-existing occupied-path and open-target checks succeed; if +occupied, aliased or equal to the incoming file it stays blank and +requires explicit user input. Already loaded documents retain their +current legitimate Save path. A real egui pointer test clicks the +existing production Save-and-open handler, verifies target preservation +and reopens both files byte-exactly. + +Only B files and these plans/insights are changed; no C generator, +A/D/E/F source, TeX/CAS/solver or PDF-to-coordinates. Classification: +UNIT_EVENT only, not physical Windows 10/M1/M4 or full Linux/X11. +#91 remains draft pending exact-head hosted fmt/test/Clippy and +independent D #92 real six-tool source-to-XeLaTeX acceptance. + +## P0 File popup click-to-edit repair — 2026-11-02 + +D I62 real Linux/X11 run 41470355844 showed a correct click into the File menu TextEdit, immediate popup collapse and failed second-file Open. Exact pinned egui 0.33.3 default closes a menu on ANY click, even inside its controls. No test-coordinate shift is appropriate. This is a verified production design blocker. + +In existing B #91 alone, File uses MenuButton + MenuConfig CloseOnClickOutside. Actual Open and Save As still call ui.close explicitly and retain safe file/dirty-confirm logic. A new headless egui Context::run(RawInput) regression uses the exact extracted production File handler to click TextEdit, replace path, click Open and check an independent CRLF/Cyrillic/opaque fixture. No C/A/D/E/F production code, separate PR, custom TeX, or workflow changes. UNIT_EVENT does NOT establish an actual successful X11 two-file Open. + +Hold merge until exact new-head Rust/Clippy/D QA passes and D #92 re-leases the new app/source tree for the real Open/Edit/Save/Reopen, dirty Cancel, inspector Apply, source/PDF/fonts and manual PNG audit. Physical Windows 10/MiKTeX and Mac ARM external XeLaTeX remain NOT_RUN. diff --git a/plans/2026-11-02_I61_B42_STUDIO_REAL_X11_ACCEPTANCE.md b/plans/2026-11-02_I61_B42_STUDIO_REAL_X11_ACCEPTANCE.md new file mode 100644 index 00000000..4e1a2f44 --- /dev/null +++ b/plans/2026-11-02_I61_B42_STUDIO_REAL_X11_ACCEPTANCE.md @@ -0,0 +1,65 @@ +# I61 · independent B42 real-egui Studio source/PDF acceptance + +Baseline B42 PR #91 HEAD `dadd05b16c0c917f28a57f233b12931fbbcb98cd`; app/src tree `64df229392c03a99ca29238bcc2e5f422c4d7409`. Independent D/integrator QA **stacked on B42**, not production B code, main, or qa/i06-n1-desktop-pointer. + +## User-visible vertical + +Real Linux/X11 `tikzarium-app` launches with isolated XDG folders and the selected external TeX Live XeLaTeX. It clicks **File → path TextEdit**, pastes the original Unicode/opaque-macro source into the actual editable source pane, presses the **GUI Save icon**, clicks grouped TikZ icons and six individual native C tools, sends six physical XTest pointer drags/clicks, and demands one additional real GUI-persisted command after each gesture. Every step asserts the original source's bytes outside validated authored C commands. The grouped popups, selected tool, pointer, saved source, F1 open/close and PDF viewport are screenshoted separately. GUI Build → PDF tab → Export must yield an external XeLaTeX one-page PDF with Cyrillic extracted and DejaVu embedded; Poppler PNG is retained. + +The tested 1366×768 control coordinates come from independently reviewed B42 bootstrap screenshot SHA `b5f717856087c397b9a87a98965f581a180e6f055256724072a240f413538cad` and group SHA `d3be3595ba89bed353a8af1a7c11a81c173ac4140722c2e1edc213e92a1e3c77`. **File-menu, individual tool and Undo coordinates are provisional until output screenshots are independently checked**, and failures must not be retried by silently accepting another UI element. + +## Remaining gates and evidence classifications + +- New exact-head GitHub Actions, actual screenshot/source/PDF and independent SHA review are **NOT_RUN at authoring**, not PASS. +- This initial full six-tool path does **not** assert two-file dirty Open, CRLF on-disk loaded-file preservation, inspector pinned Apply, visual Undo or native Ctrl+Z in the actual B42 window; report marks each NOT_RUN. They are still required for B42 merge acceptance using further independent tests, not removed requirements. +- Hosted real X11 is not physical Win10/MiKTeX or macOS ARM M1/M4. Both are **NOT_RUN**. Hosted Server MiKTeX is not physical Win10; the user Windows Server self-hosted runner remains unverified. +- No engine substitutions, TeX parser/CAS, package installer, second TikZ catalogue, source regeneration or B/C/A/F production changes. +- QA branch targets B42, never merge into main independently. Require full exact-head CI, manual screenshot/PDF/SHA review, and then exact-head merge into B42 and separate B42-to-main checks. + +First artifact review: prior QA run 41425235038 failed *inside QA driver* before native gestures. Fix X11 root/client coordinate origin and background xclip pipe inheritance in next QA HEAD. Do not mark a B42 production failure; new exact-head real process CI and screenshot calibration required. + +Second inspected artifact 13131254633: real File menu and source Ctrl+V visually PASS, path TextEdit hit at y101 failed and Save properly refused; reaccept on calibrated y93. PDF audit strengthened to require embedding for ALL records, not a single DejaVu yes. Actual six tools and exact PDF remain NOT_RUN. + +Third reviewed run 41426644654: custom-path popup TextEdit failed again despite visible real menu; no product Save defect proved. Use isolated app cwd and the GUI's visible default figure.tex, via actual toolbar Save. Custom path TextEdit NOT_RUN; new exact-head source/PDF evidence pending. + + +### Integration re-lease to frozen B42 (2026-11-02 UTC) + +Production HEAD #91 is `91e2d8201cd0c016e00d52618e4024fedb66424e`, app/src tree `64df229392c03a99ca29238bcc2e5f422c4d7409`; independent I61 QA remains a stacked PR targeting the B branch. Latest B42 bootstrap [41445054318](https://github.com/i-shutov/tikzarium/actions/runs/41445054318) reports two PNG hashes `4367ce5d798befaf2a3a157089db23df4cc5540b2d83814d94bacb15bb9f5766` and `df7cdc32f345f4988bad8de5029ababd396fb5f720f80d464945cb90f06ee1df`, identical to already manually reviewed B42 layout. Thus button coordinates are **candidate only**, not a gesture PASS. New font parser checks ALL `pdffonts` embedding flags from the right with four positive/negative offline tests, not an exit-code proxy. + +I61 still requires actual original GUI saved-source, six pointer gestures, help, XeLaTeX PDF and independent artifact SHA/manual review. Older I51/I57 B41-tree FAILs occur before GUI and are stale gates; no smoke or synthetic event-frame is a substitute. Physical Win10 MiKTeX/Mac ARM remain NOT_RUN. + + +## CI ownership migration — 2026-11-02 + +B42 is frozen at `91e2d8201cd0c016e00d52618e4024fedb66424e`, app/src tree `64df229392c03a99ca29238bcc2e5f422c4d7409`. Current independent real-X11 six-tool → GUI saved native-source → external XeLaTeX PDF evidence: [I61 run 41449047973](https://github.com/i-shutov/tikzarium/actions/runs/41449047973), original artifact 13143756910, 40/40 SHA-256 rechecked. I61 now provides B42 six-tool regression on PR/main and push/main; if a future app/src tree changes, the exact source-tree and coordinate lease must be re-reviewed rather than silently passing stale pointer positions. + +Four historical B41 fixed-tree GUI workflows (`n1-desktop-smoke` I51, `i46-b34-dirty-open` I51, `i53-b38-live-libraries` I53, and `i57-n2-four-live` I57) no longer run automatically against `main`. They retain explicit workflow_dispatch and their historical branch triggers, including the original B41 tree check: **actual tests were not removed**. Their B42 red runs failed on hardcoded tree `d290bdad...` **before any GUI**, not on a real B42 interaction. I61 does **not** substitute for their Open/Undo/library assertions. + +**Still blocked, NOT_RUN at B42 real GUI level:** two-file Open/dirty Save-and-open with CRLF and opaque macro, source/native and visual Undo after manual changes, inspector pinned Apply, deterministic Help/F1 (I61 frames 20/21 ambiguous), connected libraries; physical Win10/MiKTeX and Mac ARM XeLaTeX. These are required release gates, not waived by a green six-gesture smoke. Merge #92 into frozen #91, then #91 into main only after full independent B42 evidence and exact-head CI with reviewed original artifacts; run separate post-merge CI. + + +## I62 additional real GUI gates + +QA head f3aa1ea7 run 41458975300 was CANCELLED at 45min while acquiring 129MB Ubuntu/TeX Live packages on the Azure apt mirror, after passing app-source SHA and four PDF font-audit tests. This is CI infrastructure, not an executed GUI regression; no new source/PDF proof on this head. Switch hosted CI to Ubuntu archive mirror with bounded HTTP timeouts, while retaining external TeX Live XeLaTeX identity. Previous accepted I61 real X11 six gestures/PDF (cc339308, run 41449047973, 40/40 SHA) remain separate evidence. + +Extend the *same* I61 real-egui QA script after verified six primitive GUI Save/PDF with actual F1 Help tab two-state images, TextEdit Ctrl+Z, seventh native gesture and visual Undo, two-file CRLF/opaque macro GUI Open/Edit/Save, Ctrl+O Reopen after explicit external fixture modification, dirty Open -> Escape Cancel -> Save, and original SHA/image artifacts on failure. These new probes are NOT_RUN until exact-head CI and independent manual image/source/PDF inspection; unexpected focus/coordinate actions must remain FAIL. Inspector pinned Apply, dirty Save-and-open, project libraries, physical Win10 and Mac ARM remain NOT_RUN pending real tests. + + +## I63 integration lease — B42 File TextEdit popup repair, 2026-11-02 + +Independent integrator re-leased I61 on frozen B42 #91 `09a4df5baf0f7446bb02c68fbb126821d2a0ff9f`, verified `crates/tikzarium-app/src` Git tree `72499c6e31d4cf6a2a36d16fb7bc8b10a9d66acb` by GitHub Git tree API. Old B head `91e2d820…` and app tree `64df229392c03a99ca29238bcc2e5f422c4d7409` are historical; the QA workflow now pins the new tree and rejects any further B production drift. B only changed its File popup behavior (egui 0.33.3 CloseOnClickOutside) plus B plans/insights, preserving the C generator and all six tools. Exact B head hosted Rust CI, multi-OS D QA, generated-source XeLaTeX, hosted MiKTeX, font audits all passed; B test is `UNIT_EVENT`, not a successful real two-file X11 Open. + +One stacked integration commit combines B's three-file change with the existing #92 QA-only workflow re-lease, this plan/insights and CHANGELOG. Do not rerun old B41 hard-coded SHA guards and do not create new QA micro-PRs. New D exact-head real Linux/X11 must prove File TextEdit focus→input→real Open of independent CRLF/opaque second file, edit/save/reopen, dirty Cancel and preserve six native gestures, F1/Undo, Unicode embedded-font XeLaTeX PDF. Inspector pinned Apply remains a separate explicit real GUI release gate unless this execution proves it. Manual source/PDF/screenshot/hash audit mandatory before either merge. Physical Win10/MiKTeX, M1/M4 XeLaTeX NOT_RUN. + + +## I63 QA double-lease correction — 2026-11-02 + +The integrator caught an additional historical B app-tree SHA hardcoded in the Python real-X11 driver (not only the workflow), which would reject the correct B42 executable before GUI. Both now bind exact `72499c6e31d4cf6a2a36d16fb7bc8b10a9d66acb`; lightweight CI preflight asserts equality BEFORE installing TeX Live. Added screenshots immediately after File TextEdit pointer focus and Ctrl+A to distinguish focus from clipboard paste if two-file Open fails. Same stacked QA #92 only, no B edits/new PR. Prior first I63 head is superseded on this exact issue; any CI failure from that old head is stale QA rather than demonstrated product regression. Complete Open/Undo/Apply and physical Win10/M1/M4 remain unaccepted until evidence. + + +## I64 exact B42 pinned Inspector Apply complete-vertical gate + +I63 [real Linux/X11 41505873672](https://github.com/i-shutov/tikzarium/actions/runs/41505873672) completed SUCCESS on exact D QA HEAD `74b67c1b77a7071b2ea4ca6eae4e321e6a28f5d2` and frozen B app/source tree `72499c6e31d4cf6a2a36d16fb7bc8b10a9d66acb`: GUI File path TextEdit stayed open, loaded independent CRLF/opaque second .tex, GUI edit/save/reload, dirty Open cancellation, six native gestures, F1/Undo and external XeLaTeX PDF. Independent original artifact 13170508997 verified 64/64 SHA-256, full File/Open/dirty modal/F1 PNGs and PDF glyphs/font embedding visually reviewed. This is a concrete accepted hosted Linux/X11 subvertical, not physical Win10/M1/M4. + +To finish the remaining N1 gap on the same frozen B production tree, existing I61 script now drives actual selected Node #6 `x` TextEdit with a different `10.50cm` value, asserts draft-only Save leaves source bytes unchanged, real pinned `Применить параметры` writes exactly one C-owned Node command/metadata source line, and real inspector visual Undo restores the saved file byte-exactly. New original screenshots and SHA ledger required, no direct Rust Session fixture or `UNIT_EVENT` accepted as replacement. New exact-head CI and independent artefact manual review are PENDING. No B production edits or additional PR/workflows; physical Win10/MiKTeX and Mac ARM XeLaTeX still NOT_RUN. diff --git a/scripts/qa_b42_studio_x11.py b/scripts/qa_b42_studio_x11.py new file mode 100644 index 00000000..4e77557b --- /dev/null +++ b/scripts/qa_b42_studio_x11.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""B42 bootstrap ONLY: inspect the real egui Studio window and floating palette. + +Screenshot success is NOT six-gesture, XeLaTeX, Win10, or physical GUI PASS. +""" +from __future__ import annotations +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time + +def cmd(*args: str, timeout: int = 25) -> str: + done = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=True) + return done.stdout.strip() + +def snapshot(destination: Path) -> None: + subprocess.run( + ["import", "-window", "root", str(destination)], + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30 + ) + if destination.stat().st_size < 5000: + raise RuntimeError(f"Screenshot unexpectedly empty: {destination}") + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--app", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + options = parser.parse_args() + options.out.mkdir(parents=True, exist_ok=True) + report = { + "schema_version": 1, + "kind": "B42_ACTUAL_EGUI_BOOTSTRAP", + "source_ui": "REAL_X11_PROCESS", + "six_native_gestures": "NOT_RUN", + "actual_xelatex_pdf": "NOT_RUN", + "physical_windows10": "NOT_RUN", + "physical_mac_m1_m4": "NOT_RUN", + "manual_screenshot_review": "NOT_RUN", + "status": "FAIL", + } + app_log = (options.out / "app.log").open("w", encoding="utf-8") + process = None + try: + env = os.environ.copy() + env["TIKZARIUM_XELATEX"] = "/nonexistent/offline-studio-bootstrap" + process = subprocess.Popen( + [str(options.app.resolve())], + env=env, stdout=app_log, stderr=subprocess.STDOUT, + ) + window = cmd("xdotool", "search", "--sync", "--onlyvisible", + "--name", "TikZarium", timeout=35).splitlines()[-1] + cmd("xdotool", "windowmove", window, "0", "0") + cmd("xdotool", "windowsize", "--sync", window, "1366", "768") + cmd("xdotool", "windowactivate", "--sync", window) + time.sleep(2.0) + snapshot(options.out / "01-studio-workspace.png") + # Candidate click only; its actual position and visual result require + # manual screenshot review before calling ANY palette selected. + cmd("xdotool", "mousemove", "--window", window, "30", "146") + cmd("xdotool", "click", "1") + time.sleep(0.8) + snapshot(options.out / "02-group-palette-candidate.png") + report["palette_selection"] = "CANDIDATE_COORDINATE_NOT_VERIFIED" + report["status"] = "BOOTSTRAP_CAPTURED_REQUIRES_MANUAL_VISUAL_REVIEW" + report["window_id"] = window + except Exception as error: + report["error"] = f"{type(error).__name__}: {error}" + finally: + if process is not None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + app_log.close() + report["files"] = { + item.name: hashlib.sha256(item.read_bytes()).hexdigest() + for item in options.out.iterdir() + if item.is_file() and item.name != "report.json" + } + (options.out / "report.json").write_text( + json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8" + ) + print(json.dumps(report, ensure_ascii=False)) + return 0 if report["status"].startswith("BOOTSTRAP_CAPTURED") else 1 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qa_i61_b42_studio_x11.py b/scripts/qa_i61_b42_studio_x11.py new file mode 100644 index 00000000..d404b333 --- /dev/null +++ b/scripts/qa_i61_b42_studio_x11.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +"""I61: real B42 Studio X11 group-click -> six native TikZ gestures -> saved TeX/PDF. + +Never infer PASS from a screenshot alone; this script requires actual process +events and bytes that the running GUI persisted. Independent manual inspection +of screenshots/PDF remains mandatory. Linux/Xvfb != physical Win10 or Mac ARM. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import subprocess +import time + +from qa_n1_desktop import FIXTURE, original_preserved, owners + +# Candidate click coordinates are tied to the exact app/src Git tree and +# previously inspected B42 bootstrap screenshots, not a general GUI contract. +APP_TREE = "72499c6e31d4cf6a2a36d16fb7bc8b10a9d66acb" +BUTTONS = [ + ("line", (30, 150), (599, 169), (646, 350), (750, 377)), + ("rectangle", (30, 150), (653, 169), (681, 425), (765, 477)), + ("circle", (30, 150), (707, 169), (810, 351), (840, 351)), + ("cubic", (30, 150), (761, 169), (777, 470), (902, 460)), + ("arrow", (30, 199), (599, 216), (615, 535), (743, 552)), + ("node", (30, 248), (599, 266), (850, 550), (851, 550)), +] + + +def sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + + +def audit_embedded_fonts(output: str) -> list[str]: + """Require all PDF fonts embedded; parse the rightmost pdffonts columns. + + Type may have spaces (CID TrueType). The five final columns are + emb, sub, uni, object, ID. One nonembedded font fails the audit. + """ + rows = [line for line in output.splitlines()[2:] if line.strip()] + if not rows: + raise RuntimeError("No font records in pdffonts") + for line in rows: + columns = line.split() + flags = columns[-5:] + if ( + len(columns) < 8 or len(flags) != 5 + or any(flag.lower() not in ("yes", "no") for flag in flags[:3]) + or not flags[3].isdigit() or not flags[4].isdigit() + ): + raise RuntimeError("Unparseable PDF font row: " + line) + if flags[0].lower() != "yes": + raise RuntimeError("Nonembedded PDF font: " + line) + return rows + + +def run(*argv: str, timeout: int = 25) -> str: + result = subprocess.run(argv, capture_output=True, timeout=timeout, check=False) + if result.returncode: + raise RuntimeError(f"{argv[0]} exit {result.returncode}: " + + result.stderr.decode("utf-8", "replace")[-1400:]) + return result.stdout.decode("utf-8", "replace").strip() + + +def wait(predicate, detail: str, seconds: float = 12.0) -> None: + until = time.monotonic() + seconds + while time.monotonic() < until: + if predicate(): + return + time.sleep(0.20) + raise RuntimeError(detail) + + +def paste(contents: bytes, path: Path) -> None: + path.write_bytes(contents) + # xclip backgrounds an X11 clipboard owner. Capturing its stdout/stderr + # via run() keeps inherited pipes open, causing a false 25s timeout. + # Do not capture the detached owner; match independently accepted I47 QA. + subprocess.run(["xclip", "-selection", "clipboard", "-i", str(path)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=8, check=True) + run("xdotool", "key", "--clearmodifiers", "ctrl+v") + time.sleep(0.28) + + +def screenshot(out: Path, label: str) -> None: + filename = out / (label + ".png") + run("import", "-window", "root", str(filename), timeout=35) + if filename.stat().st_size < 5000: + raise RuntimeError(f"Empty X11 screenshot: {label}") + + +def click(window: str, x: int, y: int) -> None: + # Reviewed screenshots show ROOT pixels. xdotool --window instead uses + # client-relative coordinates and double-counts the 20px WM titlebar. + run("xdotool", "mousemove", "--sync", str(x), str(y), "click", "1") + time.sleep(0.20) + + +def key(value: str) -> None: + run("xdotool", "key", "--clearmodifiers", value) + time.sleep(0.18) + + +def gesture(window: str, start: tuple[int, int], end: tuple[int, int]) -> None: + run("xdotool", "mousemove", "--sync", str(start[0]), str(start[1]), + "mousedown", "1") + time.sleep(0.15) + run("xdotool", "mousemove", "--sync", str(end[0]), str(end[1])) + time.sleep(0.15) + run("xdotool", "mouseup", "1") + time.sleep(0.35) + + + +def held_f1() -> None: + # An instantaneous key tap may miss egui's next input frame. + run("xdotool", "keydown", "F1") + time.sleep(0.34) + run("xdotool", "keyup", "F1") + time.sleep(0.48) + + +def dock_signature(out: Path, label: str) -> str: + # Compare the right-hand actual Studio tab bar, not its changing canvas. + return run("convert", str(out / (label + ".png")), "-crop", + "330x42+1025+94", "+repage", "-format", "%#", "info:") + + +def main() -> int: + arg = argparse.ArgumentParser(description=__doc__) + arg.add_argument("--app", required=True, type=Path) + arg.add_argument("--out", required=True, type=Path) + args = arg.parse_args() + out = args.out.resolve() + if out.exists() and any(out.iterdir()): + arg.error("I61 requires an empty evidence directory") + out.mkdir(parents=True, exist_ok=True) + if os.environ.get("TIKZARIUM_APP_TREE_SHA") != APP_TREE: + arg.error("B42 app/source tree changed: re-review screenshot coordinates first") + # The real app's default file field is "figure.tex" (reviewed screenshot). + # Use an isolated GUI process working directory instead of an unproved + # popup path TextEdit click. The app must write this .tex via its Save icon. + source = out / "figure.tex" + fixture = FIXTURE.encode("utf-8") + report = { + "schema_version": 1, "kind": "I61_B42_REAL_STUDIO_GESTURES", + "status": "FAIL", "gui": "LINUX_X11_HOSTED", + "app_tree_sha": APP_TREE, + "source_head_sha": os.environ.get("TIKZARIUM_SOURCE_SHA", "UNKNOWN"), + "host": platform.platform(), "physical_win10_miktex": "NOT_RUN", + "physical_mac_m1_m4": "NOT_RUN", + "manual_image_review": "NOT_RUN", "gui_save": "NOT_RUN", + "six_real_tools": "NOT_RUN", "real_xelatex_pdf": "NOT_RUN", + "gui_open_reopen": "NOT_RUN", "visual_undo": "NOT_RUN", + "native_text_undo": "NOT_RUN", "library_inspector": "NOT_RUN", + "f1_two_state": "NOT_RUN", "gui_two_file_crlf": "NOT_RUN", + "dirty_open_cancel": "NOT_RUN", "inspector_apply": "NOT_RUN", + "steps": [], + } + process = None + app_log = (out / "app.log").open("wb") + try: + env = os.environ.copy() + env.update({ + "XDG_CONFIG_HOME": str(out / "config"), + "XDG_DATA_HOME": str(out / "data"), + "XDG_CACHE_HOME": str(out / "cache"), + "LIBGL_ALWAYS_SOFTWARE": "1", + "WINIT_UNIX_BACKEND": "x11", + }) + env.pop("WAYLAND_DISPLAY", None) + process = subprocess.Popen([str(args.app.resolve(strict=True))], + cwd=out, env=env, stdout=app_log, stderr=app_log) + found = None + until = time.monotonic() + 45 + while time.monotonic() < until: + if process.poll() is not None: + raise RuntimeError(f"Studio process exited {process.returncode}") + match = subprocess.run( + ["xdotool", "search", "--onlyvisible", "--pid", str(process.pid), + "--name", "TikZarium"], + capture_output=True, timeout=5, check=False) + if match.returncode == 0 and match.stdout.strip(): + found = match.stdout.decode().splitlines()[-1] + break + time.sleep(0.25) + if not found: + raise RuntimeError("No real visible Studio process window") + run("xdotool", "windowmove", found, "0", "0") + run("xdotool", "windowsize", "--sync", found, "1366", "768") + run("xdotool", "windowactivate", "--sync", found) + time.sleep(1.6) + report["window_id"] = found + report["app_sha256"] = sha(args.app) + screenshot(out, "01-real-studio") + + # Real File-popup screenshot confirms "figure.tex" as app default. + # GUI starts in out/, so clicking real Save writes out/figure.tex. + # Popup's editable custom path is separately NOT_RUN. + click(found, 321, 40) + screenshot(out, "02-file-menu-default-path") + click(found, 130, 209) + key("ctrl+a") + paste(fixture, out / "clipboard-source.txt") + screenshot(out, "04-source-pasted") + click(found, 177, 39) + wait(source.is_file, "Studio toolbar Save did not create default figure.tex in isolated cwd") + if source.read_bytes() != fixture: + raise RuntimeError("Real GUI Save altered opaque Unicode source") + report["file_menu_path_edit"] = "NOT_RUN_DEFAULT_FIGURE_TEX_USED" + report["gui_save"] = "PASS_REAL_X11_EXACT_BYTES" + report["steps"].append({"case": "paste_and_save", "sha256": sha(source)}) + screenshot(out, "05-source-saved") + + for index, (tool, group, tool_button, start, end) in enumerate(BUTTONS, 1): + before = source.read_bytes() + count = len(owners(before)) + click(found, *group) + screenshot(out, f"{index:02d}-{tool}-group-popup") + click(found, *tool_button) + screenshot(out, f"{index:02d}-{tool}-selected") + gesture(found, start, end) + screenshot(out, f"{index:02d}-{tool}-pointer") + # Save is the actual Studio toolbar button, no synthetic file edits. + click(found, 177, 39) + time.sleep(0.5) + after = source.read_bytes() + authored = owners(after) + if len(authored) != count + 1 or authored[-1]["tool"] != tool: + raise RuntimeError( + f"{tool}: real group/tool/pointer did not persist a new " + f"native owned command (before={count}, after={authored})") + if not original_preserved(fixture, after): + raise RuntimeError(f"{tool}: source bytes outside C command changed") + if tool == "cubic" and b".. controls " not in after: + raise RuntimeError("Cubic did not produce native TikZ Bezier") + report["steps"].append({ + "case": "real_group_and_pointer_" + tool, + "source_sha256": sha(source), + "owner_count": len(authored), + "owner_id": authored[-1]["id"], + }) + screenshot(out, f"{index:02d}-{tool}-saved") + + report["six_real_tools"] = "PASS_REAL_X11_SAVED_SOURCE" + key("F1") + screenshot(out, "20-f1-help") + key("F1") + screenshot(out, "21-f1-closed") + report["f1_keystrokes"] = "SENT_REAL_X11_VISUAL_REVIEW_REQUIRED" + report["final_source_sha256"] = sha(source) + click(found, 219, 39) # Real Studio Build icon, external XeLaTeX. + pdf = source.with_suffix(".pdf") + # The app may retain the build PDF in memory until Export; Build success + # alone must not be interpreted as a persisted generated file. + time.sleep(3.0) + screenshot(out, "22-build-status") + click(found, 73, 77) # Studio PDF tab, code pane still visible. + time.sleep(1.5) + screenshot(out, "23-pdf-view-before-export") + click(found, 259, 39) # Real export action. + wait(pdf.is_file, "Studio Export did not write a real XeLaTeX PDF", 20) + if sha(source) != report["final_source_sha256"]: + raise RuntimeError("Saved source changed during PDF export") + metadata = run("pdfinfo", str(pdf)) + if "Pages: 1" not in metadata: + raise RuntimeError("Expected one-page exact XeLaTeX PDF") + run("pdftoppm", "-f", "1", "-l", "1", "-singlefile", "-png", + "-r", "120", str(pdf), str(out / "24-exact-pdf")) + text = run("pdftotext", "-enc", "UTF-8", str(pdf), "-") + if "Не менять" not in text: + raise RuntimeError("Cyrillic opaque macro absent from real PDF") + fonts = run("pdffonts", str(pdf)) + rows = audit_embedded_fonts(fonts) + (out / "pdffonts.txt").write_text(fonts + "\n", encoding="utf-8") + (out / "pdfinfo.txt").write_text(metadata + "\n", encoding="utf-8") + if not any("DejaVu" in line for line in rows): + raise RuntimeError("Expected DejaVu embedded font absent") + report["real_xelatex_pdf"] = "PASS_EXTERNAL_XELATEX_PDF_CYRILLIC_AND_ALL_FONTS_EMBEDDED" + report["pdf_sha256"] = sha(pdf) + screenshot(out, "25-pdf-exported") + + # I62: real desktop assertions follow the already-proven six gestures + # and PDF. Source bytes and screenshots must be independently reviewed. + click(found, 28, 77) # Return to the guide from the PDF viewport. + click(found, 1055, 109) # Real Properties tab. + click(found, 402, 39) # Help toolbar ON. + click(found, 402, 39) # Help toolbar OFF; deterministic initial state. + screenshot(out, "26-properties-before-f1") + properties = dock_signature(out, "26-properties-before-f1") + held_f1() + screenshot(out, "27-f1-help-open") + opened = dock_signature(out, "27-f1-help-open") + held_f1() + screenshot(out, "28-f1-help-closed") + closed = dock_signature(out, "28-f1-help-closed") + if opened == properties or closed != properties: + raise RuntimeError("Real F1 failed Properties -> Help -> Properties tab transition") + report["f1_two_state"] = "PASS_REAL_X11_TAB_CHANGE_MANUAL_LABEL_REVIEW_REQUIRED" + + # I64: an actual GUI Inspector edit, not a headless Rust event or + # a direct Session patch. Node #6 is selected; use one shared-C draft. + inspector_before = source.read_bytes() + before_items = owners(inspector_before) + if len(before_items) != 6 or before_items[-1]["tool"] != "node": + raise RuntimeError("Inspector Apply requires selected native Node #6") + screenshot(out, "28a-inspector-before-draft") + click(found, 1080, 560) # Actual selected Node x TextEdit in Properties dock. + key("ctrl+a") + paste(b"10.50cm", out / "clipboard-inspector-node-x.txt") + screenshot(out, "28b-inspector-draft-only") + click(found, 177, 39) # GUI Save must NOT apply an inspector draft. + if source.read_bytes() != inspector_before: + raise RuntimeError("Inspector draft unexpectedly changed saved source") + click(found, 1235, 157) # Actual pinned 'Применить параметры' control. + screenshot(out, "28c-inspector-applied") + click(found, 177, 39) + wait(lambda: source.read_bytes() != inspector_before, + "Real pinned Inspector Apply did not persist a source patch") + inspector_applied = source.read_bytes() + before_lines = inspector_before.splitlines(keepends=True) + after_lines = inspector_applied.splitlines(keepends=True) + if len(before_lines) != len(after_lines): + raise RuntimeError("Inspector Apply changed source line count") + altered = [i for i, (left, right) in enumerate(zip(before_lines, after_lines)) + if left != right] + if ( + len(altered) != 1 + or b"\\node at (10.50cm,5.00cm)" not in after_lines[altered[0]] + or b'"x":"10.50cm"' not in after_lines[altered[0]] + or len(owners(inspector_applied)) != 6 + or not original_preserved(fixture, inspector_applied) + ): + raise RuntimeError("Inspector Apply did not make a minimal C-owned Node x patch") + report["steps"].append({ + "case": "real_pinned_inspector_node_x_apply_minimal_source_patch", + "before_sha256": hashlib.sha256(inspector_before).hexdigest(), + "after_sha256": sha(source), + "changed_line_number": altered[0] + 1, + }) + click(found, 1085, 157) # Same real inspector's visual Undo. + screenshot(out, "28d-inspector-apply-undo") + click(found, 177, 39) + wait(lambda: source.read_bytes() == inspector_before, + "Visual Undo after pinned Inspector Apply failed byte-exact source restore") + report["inspector_apply"] = "PASS_REAL_X11_C_CATALOGUE_NODE_X_APPLY_AND_UNDO" + + # TextEdit Ctrl+Z and Session visual inverse-patch Undo are different. + six_bytes = source.read_bytes() + manual = b"% I62_NATIVE_UNDO_MARKER" + click(found, 180, 380) + key("ctrl+End") + paste(manual, out / "clipboard-manual-undo.txt") + screenshot(out, "29-manual-source-edited") + click(found, 177, 39) + wait(lambda: manual in source.read_bytes(), "Real manual GUI edit was not persisted") + if source.read_bytes() != six_bytes + manual: + raise RuntimeError("Manual edit rewrote unrelated source bytes") + click(found, 180, 380) + key("ctrl+End") + key("ctrl+z") + screenshot(out, "30-native-ctrl-z") + click(found, 177, 39) + wait(lambda: source.read_bytes() == six_bytes, + "Native Ctrl+Z failed to restore exactly six-tool source") + report["native_text_undo"] = "PASS_REAL_X11_SAVED_SOURCE_BYTES" + + click(found, 30, 150) # Geometry group. + click(found, 599, 169) # Native shared-C line. + gesture(found, (845, 400), (948, 421)) + click(found, 177, 39) + wait(lambda: len(owners(source.read_bytes())) == 7, + "Seventh actual GUI gesture did not persist a native command") + screenshot(out, "31-seven-before-visual-undo") + click(found, 1085, 157) # Visible inspector visual Undo. + screenshot(out, "32-visual-undo-click") + click(found, 177, 39) + wait(lambda: source.read_bytes() == six_bytes, + "Visual inverse-patch Undo failed to restore six-tool source") + report["visual_undo"] = "PASS_REAL_X11_INVERSE_SOURCE_BYTES" + + # The second file is an explicit test INPUT, never forged GUI output. + secondary = out / "second-crlf.tex" + secondary_input = ( + "\\documentclass{standalone}\r\n" + "\\usepackage{tikz}\r\n" + "% I62 preserve original CRLF comment\r\n" + "\\newcommand{\\OpaqueSecond}{Не менять}\r\n" + "\\begin{document}\r\n" + "\\begin{tikzpicture}\r\n" + "\\node at (0,0) {\\OpaqueSecond};\r\n" + "\\end{tikzpicture}\r\n" + "\\end{document}\r\n" + ).encode("utf-8") + secondary.write_bytes(secondary_input) + click(found, 321, 40) + time.sleep(0.45) + screenshot(out, "33-file-menu-before-open") + click(found, 440, 93) # Exact root-pixel point inside File TextEdit. + screenshot(out, "33a-file-path-focused") + key("ctrl+a") + screenshot(out, "33b-file-path-selected") + paste(b"second-crlf.tex", out / "clipboard-open-path.txt") + screenshot(out, "34-second-path-entered") + click(found, 351, 124) # Actual GUI 'Открыть путь'. + time.sleep(0.75) + screenshot(out, "35-second-opened") + append = b"% I62_GUI_SECOND_SAVED" + click(found, 177, 380) + key("ctrl+End") + paste(append, out / "clipboard-second-edit.txt") + click(found, 177, 39) + wait(lambda: append in secondary.read_bytes(), + "Real second-file GUI Open/Edit/Save did not persist") + if secondary.read_bytes() != secondary_input + append: + raise RuntimeError("Second-file GUI Save normalized CRLF or opaque bytes") + if source.read_bytes() != six_bytes: + raise RuntimeError("Second-file Save corrupted first document") + screenshot(out, "36-second-crlf-saved") + report["gui_two_file_crlf"] = "PASS_REAL_X11_TWO_FILE_GUI_OPEN_SAVE" + report["file_menu_path_edit"] = "PASS_REAL_X11_EDITABLE_FILE_PATH" + + # Explicit external test-fixture modification proves Ctrl+O reads disk. + external = b"% I62_EXTERNAL_BEFORE_REOPEN" + secondary.write_bytes(secondary_input + append + external) + key("ctrl+o") + time.sleep(0.65) + screenshot(out, "37-second-reopened") + after_reopen = b"% I62_AFTER_GUI_REOPEN" + click(found, 177, 380) + key("ctrl+End") + paste(after_reopen, out / "clipboard-reopen-edit.txt") + click(found, 177, 39) + wait(lambda: after_reopen in secondary.read_bytes(), + "Reopened GUI document cannot be edited and saved") + if secondary.read_bytes() != secondary_input + append + external + after_reopen: + raise RuntimeError("GUI Ctrl+O failed to reload external CRLF source") + report["gui_open_reopen"] = "PASS_REAL_X11_BYTE_EXACT_RELOAD" + + # Real dirty Open -> Cancel: never discard the original unsaved edit. + pending = b"% I62_DIRTY_CANCEL_SURVIVES" + click(found, 177, 380) + key("ctrl+End") + paste(pending, out / "clipboard-dirty-source.txt") + screenshot(out, "38-dirty-source") + click(found, 321, 40) + time.sleep(0.44) + click(found, 440, 93) + key("ctrl+a") + paste(b"figure.tex", out / "clipboard-first-path.txt") + click(found, 351, 124) + time.sleep(0.55) + screenshot(out, "39-pending-dirty-open") + key("Escape") + time.sleep(0.40) + screenshot(out, "40-dirty-open-cancelled") + click(found, 177, 39) + wait(lambda: pending in secondary.read_bytes(), + "Dirty Open Cancel discarded unsaved source") + if secondary.read_bytes() != secondary_input + append + external + after_reopen + pending: + raise RuntimeError("Dirty Open cancel corrupted second source") + if source.read_bytes() != six_bytes: + raise RuntimeError("Dirty Open changed original first document") + report["dirty_open_cancel"] = "PASS_REAL_X11_CANCEL_AND_SAVE_BYTES" + report["steps"].append({ + "case": "f1_native_visual_undo_two_file_crlf_reopen_dirty_cancel", + "first_source_sha256": sha(source), "second_source_sha256": sha(secondary) + }) + + report["status"] = "PASS_AUTOMATED_REAL_X11_REQUIRES_MANUAL_ARTIFACT_REVIEW" + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + finally: + if process: + process.terminate() + try: + process.wait(timeout=8) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + app_log.close() + report["files"] = { + file.name: sha(file) for file in sorted(out.iterdir()) + if file.is_file() and file.name != "report.json" + } + (out / "report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(json.dumps(report, ensure_ascii=False)) + return 0 if report["status"].startswith("PASS_AUTOMATED") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_qa_i61_studio_fonts.py b/scripts/tests/test_qa_i61_studio_fonts.py new file mode 100644 index 00000000..ed1122b7 --- /dev/null +++ b/scripts/tests/test_qa_i61_studio_fonts.py @@ -0,0 +1,33 @@ +"""I61 offline PDF font proof contract tests, not actual GUI evidence.""" +from pathlib import Path +import sys +import unittest +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from qa_i61_b42_studio_x11 import audit_embedded_fonts + +HEADER = ("name type encoding emb sub uni object ID\n" + "-------------------------------\n") + +class AuditFonts(unittest.TestCase): + def test_all_embedded_including_two_word_type(self): + lines = audit_embedded_fonts( + HEADER + "DejaVuSerif TrueType WinAnsi yes yes yes 6 0\n" + + "LatinModernMath CID TrueType Identity-H yes yes yes 12 0\n") + self.assertEqual(len(lines), 2) + + def test_single_nonembedded_font_fails(self): + with self.assertRaisesRegex(RuntimeError, "Nonembedded"): + audit_embedded_fonts( + HEADER + "DejaVuSerif TrueType WinAnsi yes yes yes 6 0\n" + + "OtherFont TrueType WinAnsi no no yes 8 0\n") + + def test_malformed_row_fails(self): + with self.assertRaisesRegex(RuntimeError, "Unparseable"): + audit_embedded_fonts(HEADER + "BadFont not enough fields\n") + + def test_empty_inventory_fails(self): + with self.assertRaisesRegex(RuntimeError, "No font records"): + audit_embedded_fonts(HEADER) + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tools/qa_windows_server_user.ps1 b/scripts/tools/qa_windows_server_user.ps1 new file mode 100644 index 00000000..74e4d35b --- /dev/null +++ b/scripts/tools/qa_windows_server_user.ps1 @@ -0,0 +1,175 @@ +# User-installed external MiKTeX on persistent Windows Server. +# NO installer, package writes, registry settings or hidden engine fallback. +[CmdletBinding()] +param([Parameter(Mandatory)][string]$OutputDir,[switch]$CheckRust) +$ErrorActionPreference='Stop' +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null +$reportPath=Join-Path $OutputDir 'windows-server-qa.json' +$report=[ordered]@{ + schema_version=1;status='INCOMPLETE';environment='USER_WINDOWS_SERVER' + physical_windows10='NOT_RUN';mac_m1_m4='NOT_RUN';interactive_gui='NOT_RUN' + cargo='NOT_RUN';font_audit='NOT_RUN';text_audit='NOT_RUN';raster='NOT_RUN';package_autoinstall='DISABLED_BY_FLAG' +} +$exit=1 +try { + $root=(Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + Push-Location $root + try { + $report.sha=(& git rev-parse HEAD | Out-String).Trim() + if($LASTEXITCODE -ne 0 -or $report.sha -notmatch '^[0-9a-f]{40}$'){throw 'Git SHA not established'} + $os=Get-CimInstance Win32_OperatingSystem + $report.os=$os.Caption;$report.build=$os.BuildNumber + $engine=$env:TIKZARIUM_XELATEX + if([string]::IsNullOrWhiteSpace($engine)){ + $cmd=Get-Command xelatex -ErrorAction SilentlyContinue + if(-not $cmd){throw 'External XeLaTeX missing; no fallback'} + $engine=$cmd.Source + } + if(-not (Test-Path -LiteralPath $engine -PathType Leaf)){throw 'Selected XeLaTeX does not exist'} + $banner=(& $engine '--disable-installer' '--version' 2>&1 | Out-String) + if($LASTEXITCODE -ne 0 -or $banner -notmatch 'MiKTeX-XeTeX'){ + throw 'Only verified MiKTeX XeTeX --disable-installer is permitted' + } + $report.engine=$engine + $report.version=(($banner -split '\r?\n') | Where-Object { $_ })[0] + $miktex=Get-Command miktex -ErrorAction SilentlyContinue + if(-not $miktex){throw 'MiKTeX read-only inventory utility is missing'} + function Inventory([string]$snapshot) { + $rows=@(& $miktex.Source packages list --template '{id}|{isInstalled}' 2>$null) + if($LASTEXITCODE -ne 0 -or $rows.Count -eq 0){throw 'MiKTeX inventory unavailable'} + $items=@($rows | ForEach-Object {[string]$_} | Sort-Object) + foreach($line in $items){ + if($line -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]*\|(true|false)$'){ + throw 'MiKTeX inventory output is noncanonical' + } + } + $items | Set-Content -LiteralPath $snapshot -Encoding utf8 + return $items + } + $before=@(Inventory (Join-Path $OutputDir 'packages-before.txt')) + $work=Join-Path $OutputDir 'scratch' + New-Item -ItemType Directory -Force -Path $work | Out-Null + $original=Join-Path $root 'examples\cyrillic.tex' + $source=Join-Path $work 'cyrillic.tex' + $sourceHash=(Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash + Copy-Item -LiteralPath $original -Destination $source -Force + $args=@('--disable-installer','-no-shell-escape','-halt-on-error', + '-interaction=nonstopmode','-file-line-error','-recorder', + "-output-directory=$work",$source) + Push-Location $work + try { + & $engine @args *> (Join-Path $OutputDir 'xelatex-output.log') + $texExit=$LASTEXITCODE + } finally {Pop-Location} + $report.tex_exit=$texExit + if($texExit -ne 0){throw "Real XeLaTeX failed with exit $texExit"} + $pdf=Join-Path $work 'cyrillic.pdf' + if(-not (Test-Path -LiteralPath $pdf -PathType Leaf)){throw 'No PDF produced'} + if((Get-FileHash $original -Algorithm SHA256).Hash -ne $sourceHash -or + (Get-FileHash $source -Algorithm SHA256).Hash -ne $sourceHash){ + throw 'Input source changed during compiler run' + } + $report.source_sha256=$sourceHash.ToLowerInvariant() + $report.pdf_sha256=(Get-FileHash $pdf -Algorithm SHA256).Hash.ToLowerInvariant() + # A single /yes/ anywhere in pdffonts is insufficient: each PDF font + # needs its own embedding verdict. pdftotext is an independent Cyrillic + # content check; neither means glyphs were visually accepted on a GUI. + $fonts=Get-Command pdffonts -ErrorAction SilentlyContinue + $info=Get-Command pdfinfo -ErrorAction SilentlyContinue + if($fonts -and $info){ + $details=(& $info.Source $pdf 2>&1 | Out-String) + $infoExit=$LASTEXITCODE + $fontsText=(& $fonts.Source $pdf 2>&1 | Out-String) + $fontsExit=$LASTEXITCODE + $details | Set-Content -LiteralPath (Join-Path $OutputDir 'pdfinfo.txt') -Encoding utf8 + $fontsText | Set-Content -LiteralPath (Join-Path $OutputDir 'pdffonts.txt') -Encoding utf8 + if($infoExit -ne 0 -or $fontsExit -ne 0 -or + $details -notmatch '(?m)^Pages:\s+1\s*$'){ + throw 'PDF metadata or one-page contract failed' + } + $lines=@($fontsText -split '\r?\n' | Where-Object { $_.Trim() }) + if($lines.Count -lt 3 -or $lines[0] -notmatch '\bemb\b'){ + throw 'pdffonts output missing table header or font records' + } + $fontRows=@($lines | Select-Object -Skip 2) + if($fontRows.Count -eq 0){throw 'PDF has no font records'} + foreach($row in $fontRows){ + $match=[regex]::Match($row, + '\s+(yes|no)\s+(yes|no)\s+(yes|no)\s+\d+\s+\d+\s*$', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if(-not $match.Success){throw "Unparseable pdffonts record: $row"} + if($match.Groups[1].Value -ine 'yes'){ + throw "Nonembedded PDF font: $row" + } + } + if(($fontRows -join ' ') -notmatch '(?i)DejaVu.?Serif'){ + throw 'Cyrillic fixture did not embed the expected DejaVu Serif family' + } + $report.font_count=$fontRows.Count + $report.font_audit='PASS_ALL_FONT_ROWS_EMBEDDED' + }else{ + $report.font_audit='NOT_RUN_PDFINFO_OR_PDFFONTS_MISSING' + } + $extractor=Get-Command pdftotext -ErrorAction SilentlyContinue + if($extractor){ + $extracted=Join-Path $OutputDir 'pdftotext.txt' + & $extractor.Source '-enc' 'UTF-8' $pdf $extracted + if($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $extracted)){ + throw 'pdftotext extraction failed' + } + $actualText=Get-Content -LiteralPath $extracted -Raw -Encoding utf8 + foreach($expected in @('Тест XeLaTeX','Кириллица','Точка')){ + if(-not $actualText.Contains($expected)){ + throw "Expected Cyrillic content absent in PDF extraction: $expected" + } + } + $report.text_audit='PASS_EXPECTED_CYRILLIC_EXTRACTED' + }else{ + $report.text_audit='NOT_RUN_PDFTOTEXT_MISSING' + } + $rasterizer=Get-Command pdftoppm -ErrorAction SilentlyContinue + if($rasterizer){ + $prefix=Join-Path $OutputDir 'cyrillic-render' + & $rasterizer.Source '-f' '1' '-l' '1' '-singlefile' '-png' '-r' '144' $pdf $prefix + if($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath ($prefix+'.png'))){ + throw 'PDF raster generation failed' + } + $report.raster='GENERATED_REQUIRES_MANUAL_GLYPH_REVIEW' + }else{ + $report.raster='NOT_RUN_PDFTOPPM_MISSING' + } + $after=@(Inventory (Join-Path $OutputDir 'packages-after.txt')) + if($before.Count -ne $after.Count -or (Compare-Object $before $after)){ + throw 'Package inventory changed; offline gate is FAIL' + } + $report.packages_unchanged=$true + $report.package_count=$after.Count + if($CheckRust){ + if(-not (Get-Command cargo -ErrorAction SilentlyContinue)){ + $report.cargo='NOT_RUN_NO_CARGO' + }else{ + $env:CARGO_BUILD_JOBS='1' + $env:CARGO_INCREMENTAL='0' + & cargo test --locked -p tikzarium-core -p tikzarium-extensions -p tikzarium-app + if($LASTEXITCODE -ne 0){throw 'Cargo locked tests failed'} + $report.cargo='PASS_WINDOWS_SERVER_NOT_WIN10' + } + } + $complete=($report.font_audit -eq 'PASS_ALL_FONT_ROWS_EMBEDDED' -and + $report.text_audit -eq 'PASS_EXPECTED_CYRILLIC_EXTRACTED' -and + (-not $CheckRust -or $report.cargo -eq 'PASS_WINDOWS_SERVER_NOT_WIN10')) + $report.status=if($complete){ + 'PASS_USER_WINDOWS_SERVER_TEX_FONT_AND_EXTRACTED_TEXT' + }else{'PARTIAL_REQUIRED_FONT_TEXT_OR_RUST_CHECK_NOT_RUN'} + # A PARTIAL report must not make the self-hosted workflow green. + $exit=if($complete){0}else{2} + }finally{Pop-Location} +}catch{ + $report.status='FAIL' + $report.error=$_.Exception.Message + Write-Error $report.error -ErrorAction Continue +}finally{ + $report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $reportPath -Encoding utf8 + Write-Host "Windows Server QA report: $reportPath" +} +exit $exit