From 9fd76aef3ae2810667d3f2b031fc43692fc04c89 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 13 Sep 2026 01:43:22 -0700 Subject: [PATCH] Add current-draft diagnostic underlines and passive hover Preserve raw include diagnostic provenance and derive bands from the existing shaped editor. Retain deferred tooltip paragraphs through rendering. Include source, regression, failure and native-trial evidence; corrected native acceptance remains pending. --- .gitattributes | 4 + Cargo.lock | 3 +- Cargo.toml | 4 + crates/oa-cli/src/lib.rs | 4 +- crates/oa-core/src/source_files.rs | 10 +- crates/oa-core/tests/source_files.rs | 84 + crates/oa-desktop/Cargo.toml | 1 + crates/oa-desktop/src/app.rs | 280 + crates/oa-desktop/src/app/text_workspace.rs | 18 + .../src/app/text_workspace/tests.rs | 75 + crates/oa-desktop/src/main.rs | 1 + crates/oa-desktop/src/source_annotations.rs | 309 + .../src/source_annotations/anchor_tests.rs | 260 + .../src/source_annotations/layout_tests.rs | 321 + .../source_annotations/performance_tests.rs | 216 + .../src/source_annotations/tests.rs | 83 + .../src/source_annotations/widget.rs | 425 ++ .../src/source_annotations/widget/tests.rs | 1052 ++++ crates/oa-desktop/src/views/text_workspace.rs | 10 +- .../vendor/iced_widget/.cargo_vcs_info.json | 6 + crates/vendor/iced_widget/ARCADIA-PATCH.md | 31 + crates/vendor/iced_widget/Cargo.toml | 126 + crates/vendor/iced_widget/Cargo.toml.orig | 51 + crates/vendor/iced_widget/LICENSE | 18 + .../vendor/iced_widget/UPSTREAM-MANIFEST.json | 352 ++ .../vendor/iced_widget/assets/iced-logo.svg | 2 + crates/vendor/iced_widget/src/action.rs | 85 + crates/vendor/iced_widget/src/button.rs | 752 +++ crates/vendor/iced_widget/src/canvas.rs | 327 + .../vendor/iced_widget/src/canvas/program.rs | 111 + crates/vendor/iced_widget/src/checkbox.rs | 707 +++ crates/vendor/iced_widget/src/column.rs | 617 ++ crates/vendor/iced_widget/src/combo_box.rs | 1013 ++++ crates/vendor/iced_widget/src/container.rs | 660 +++ crates/vendor/iced_widget/src/float.rs | 432 ++ crates/vendor/iced_widget/src/grid.rs | 415 ++ crates/vendor/iced_widget/src/helpers.rs | 2107 +++++++ crates/vendor/iced_widget/src/image.rs | 418 ++ crates/vendor/iced_widget/src/image/viewer.rs | 447 ++ crates/vendor/iced_widget/src/keyed.rs | 75 + crates/vendor/iced_widget/src/keyed/column.rs | 398 ++ crates/vendor/iced_widget/src/lazy.rs | 417 ++ crates/vendor/iced_widget/src/lazy/cache.rs | 14 + .../vendor/iced_widget/src/lazy/component.rs | 669 +++ crates/vendor/iced_widget/src/lazy/helpers.rs | 43 + crates/vendor/iced_widget/src/lib.rs | 153 + crates/vendor/iced_widget/src/markdown.rs | 1722 ++++++ crates/vendor/iced_widget/src/mouse_area.rs | 437 ++ crates/vendor/iced_widget/src/overlay.rs | 2 + crates/vendor/iced_widget/src/overlay/menu.rs | 661 +++ crates/vendor/iced_widget/src/pane_grid.rs | 1343 +++++ .../vendor/iced_widget/src/pane_grid/axis.rs | 259 + .../src/pane_grid/configuration.rs | 26 + .../iced_widget/src/pane_grid/content.rs | 439 ++ .../iced_widget/src/pane_grid/controls.rs | 58 + .../iced_widget/src/pane_grid/direction.rs | 12 + .../iced_widget/src/pane_grid/draggable.rs | 8 + .../vendor/iced_widget/src/pane_grid/node.rs | 381 ++ .../vendor/iced_widget/src/pane_grid/pane.rs | 5 + .../vendor/iced_widget/src/pane_grid/split.rs | 5 + .../vendor/iced_widget/src/pane_grid/state.rs | 468 ++ .../iced_widget/src/pane_grid/title_bar.rs | 632 ++ crates/vendor/iced_widget/src/pick_list.rs | 929 +++ crates/vendor/iced_widget/src/pin.rs | 271 + crates/vendor/iced_widget/src/progress_bar.rs | 333 ++ crates/vendor/iced_widget/src/qr_code.rs | 439 ++ crates/vendor/iced_widget/src/radio.rs | 551 ++ crates/vendor/iced_widget/src/responsive.rs | 207 + crates/vendor/iced_widget/src/row.rs | 605 ++ crates/vendor/iced_widget/src/rule.rs | 325 + crates/vendor/iced_widget/src/scrollable.rs | 2437 ++++++++ crates/vendor/iced_widget/src/sensor.rs | 447 ++ crates/vendor/iced_widget/src/shader.rs | 200 + .../vendor/iced_widget/src/shader/program.rs | 60 + crates/vendor/iced_widget/src/slider.rs | 702 +++ crates/vendor/iced_widget/src/space.rs | 104 + crates/vendor/iced_widget/src/stack.rs | 405 ++ crates/vendor/iced_widget/src/svg.rs | 340 ++ crates/vendor/iced_widget/src/table.rs | 725 +++ crates/vendor/iced_widget/src/text.rs | 30 + crates/vendor/iced_widget/src/text/rich.rs | 563 ++ crates/vendor/iced_widget/src/text_editor.rs | 1534 +++++ crates/vendor/iced_widget/src/text_input.rs | 1815 ++++++ .../iced_widget/src/text_input/cursor.rs | 189 + .../iced_widget/src/text_input/editor.rs | 70 + .../iced_widget/src/text_input/value.rs | 136 + crates/vendor/iced_widget/src/themer.rs | 296 + crates/vendor/iced_widget/src/toggler.rs | 613 ++ crates/vendor/iced_widget/src/tooltip.rs | 584 ++ .../vendor/iced_widget/src/vertical_slider.rs | 567 ++ harness/DECISIONS.md | 14 + .../text-diagnostics-20260913/README.md | 145 + .../inputs.json | 2418 ++++++++ .../inputs.zip | Bin 0 -> 2446684 bytes .../inputs.json | 2406 ++++++++ .../inputs.zip | Bin 0 -> 2429659 bytes .../inputs.json | 2418 ++++++++ .../inputs.zip | Bin 0 -> 2444701 bytes .../inputs.json | 2418 ++++++++ .../inputs.zip | Bin 0 -> 2445071 bytes .../inputs.json | 2118 +++++++ .../inputs.zip | Bin 0 -> 2068407 bytes .../inputs.json | 2418 ++++++++ .../inputs.zip | Bin 0 -> 2445125 bytes .../inputs.json | 2418 ++++++++ .../inputs.zip | Bin 0 -> 2445080 bytes .../inputs.json | 2418 ++++++++ .../inputs.zip | Bin 0 -> 2443473 bytes .../inputs.json | 2418 ++++++++ .../inputs.zip | Bin 0 -> 2446691 bytes ...reclaim-candidates-2-superseded-draft.json | 196 + .../closed-binary-reclaim-candidates-2.json | 150 + .../closed-binary-reclaim-candidates-2.md | 22 + .../closed-binary-reclaim-candidates-3.json | 150 + .../closed-binary-reclaim-candidates-3.md | 16 + .../closed-binary-reclaim-candidates.json | 191 + .../closed-binary-reclaim-candidates.md | 22 + .../closed-binary-reclaim-completed-2.json | 14 + .../closed-binary-reclaim-completed-3.json | 16 + .../closed-binary-reclaim-completed.json | 16 + .../closed-duplicate-binary-cleanup.json | 26 + .../cleanup/reclaim-process-readback-2.json | 5 + .../cleanup/reclaim-process-readback-3.json | 4 + .../cleanup/reclaim-process-readback.json | 5 + .../copy-record.json | 4185 +++++++++++++ .../environment/env.ps1 | 7 + .../text-diagnostics-20260913/files.json | 1538 +++++ .../adopted-workspace-metadata.json | 1 + .../gate-helpers/gate.py | 40 + .../gate-helpers/install-prepared-tests.py | 29 + .../prepared-app-annotation-tests.rs | 50 + .../gates/annotation-admission-1/result.json | 87 + .../gates/annotation-admission-1/started.json | 35 + .../gates/annotation-admission-1/stderr.log | 2 + .../gates/annotation-admission-1/stdout.log | 322 + .../gates/annotation-attempt-1/result.json | 87 + .../gates/annotation-attempt-1/started.json | 35 + .../gates/annotation-attempt-1/stderr.log | 7 + .../gates/annotation-attempt-1/stdout.log | 331 ++ .../gates/annotation-corrected-1/result.json | 89 + .../gates/annotation-corrected-1/started.json | 37 + .../gates/annotation-corrected-1/stderr.log | 55 + .../gates/annotation-corrected-1/stdout.log | 367 ++ .../gates/annotation-manual-1/result.json | 89 + .../gates/annotation-manual-1/started.json | 37 + .../gates/annotation-manual-1/stderr.log | 25 + .../gates/annotation-manual-1/stdout.log | 337 ++ .../annotation-projection-cost-2/result.json | 89 + .../annotation-projection-cost-2/started.json | 37 + .../annotation-projection-cost-2/stderr.log | 55 + .../annotation-projection-cost-2/stdout.log | 367 ++ .../gates/desktop-cli-final-1/result.json | 238 + .../gates/desktop-cli-final-1/started.json | 34 + .../gates/desktop-cli-final-1/stderr.log | 8 + .../gates/desktop-cli-final-1/stdout.log | 1258 ++++ .../gates/desktop-cli-final-2/result.json | 238 + .../gates/desktop-cli-final-2/started.json | 34 + .../gates/desktop-cli-final-2/stderr.log | 6 + .../gates/desktop-cli-final-2/stdout.log | 1260 ++++ .../gates/desktop-full-1/result.json | 84 + .../gates/desktop-full-1/started.json | 32 + .../gates/desktop-full-1/stderr.log | 2 + .../gates/desktop-full-1/stdout.log | 1221 ++++ .../gates/format-final-1/result.json | 43 + .../gates/format-final-1/started.json | 30 + .../gates/format-final-1/stderr.log | 0 .../gates/format-final-1/stdout.log | 0 .../gates/format-final-2/result.json | 43 + .../gates/format-final-2/started.json | 30 + .../gates/format-final-2/stderr.log | 0 .../gates/format-final-2/stdout.log | 0 .../gates/format-final-3/result.json | 48 + .../gates/format-final-3/started.json | 35 + .../gates/format-final-3/stderr.log | 0 .../gates/format-final-3/stdout.log | 0 .../gates/mounted-annotation-1/result.json | 87 + .../gates/mounted-annotation-1/started.json | 35 + .../gates/mounted-annotation-1/stderr.log | 3 + .../gates/mounted-annotation-1/stdout.log | 338 ++ .../gates/provenance-app-1/result.json | 87 + .../gates/provenance-app-1/started.json | 35 + .../gates/provenance-app-1/stderr.log | 2 + .../gates/provenance-app-1/stdout.log | 325 + .../gates/provenance-core-full-1/result.json | 1718 ++++++ .../gates/provenance-core-full-1/started.json | 32 + .../gates/provenance-core-full-1/stderr.log | 43 + .../gates/provenance-core-full-1/stdout.log | 741 +++ .../gates/strict-clippy-1/result.json | 48 + .../gates/strict-clippy-1/started.json | 35 + .../gates/strict-clippy-1/stderr.log | 7 + .../gates/strict-clippy-1/stdout.log | 318 + .../gates/strict-clippy-2/result.json | 48 + .../gates/strict-clippy-2/started.json | 35 + .../gates/strict-clippy-2/stderr.log | 2 + .../gates/strict-clippy-2/stdout.log | 324 + .../gates/strict-clippy-3/result.json | 48 + .../gates/strict-clippy-3/started.json | 35 + .../gates/strict-clippy-3/stderr.log | 5 + .../gates/strict-clippy-3/stdout.log | 372 ++ .../gates/strict-clippy-4/result.json | 48 + .../gates/strict-clippy-4/started.json | 35 + .../gates/strict-clippy-4/stderr.log | 2 + .../gates/strict-clippy-4/stdout.log | 318 + .../gates/strict-clippy-5/result.json | 53 + .../gates/strict-clippy-5/started.json | 40 + .../gates/strict-clippy-5/stderr.log | 7 + .../gates/strict-clippy-5/stdout.log | 372 ++ .../tooltip-lifetime-focused-1/result.json | 85 + .../tooltip-lifetime-focused-1/started.json | 33 + .../tooltip-lifetime-focused-1/stderr.log | 3 + .../tooltip-lifetime-focused-1/stdout.log | 340 ++ .../gates/windows-build-final-1/result.json | 124 + .../gates/windows-build-final-1/started.json | 34 + .../gates/windows-build-final-1/stderr.log | 6 + .../gates/windows-build-final-1/stdout.log | 307 + .../gates/windows-build-final-2/result.json | 86 + .../gates/windows-build-final-2/started.json | 34 + .../gates/windows-build-final-2/stderr.log | 7 + .../gates/windows-build-final-2/stdout.log | 306 + .../text-diagnostics-20260913/index.json | 3584 +++++++++++ .../licenses/LICENSE | 21 + .../licenses/iced-upstream-54abf81-LICENSE | 18 + .../native-preparation/build-and-preserve.py | 35 + .../candidate-oracle-1/eof.stderr.log | 0 .../candidate-oracle-1/eof.stdout.json | 30 + .../candidate-oracle-1/eof/functions.arcadia | 33 + .../candidate-oracle-1/eof/project.arcadia | 4 + .../candidate-oracle-1/result.json | 220 + .../candidate-oracle-1/token.stderr.log | 0 .../candidate-oracle-1/token.stdout.json | 30 + .../token/functions.arcadia | 33 + .../candidate-oracle-1/token/project.arcadia | 4 + .../native-preparation/candidate-oracle.py | 100 + .../current-oracle-1/complete.stderr.log | 0 .../current-oracle-1/complete.stdout.json | 97 + .../complete/functions.arcadia | 31 + .../current-oracle-1/complete/project.arcadia | 4 + .../current-oracle-1/missing.stderr.log | 0 .../current-oracle-1/missing.stdout.json | 46 + .../current-oracle-1/missing/project.arcadia | 4 + .../current-oracle-1/result.json | 294 + .../native-preparation/current-oracle.py | 98 + .../fixtures/functions-to-discover.arcadia | 31 + .../fixtures/missing-include/project.arcadia | 4 + .../README.md | 17 + .../complete-fixture/functions.arcadia | 31 + .../complete-fixture/project.arcadia | 4 + .../complete.stderr.log | 0 .../complete.stdout.json | 97 + .../missing.stderr.log | 0 .../missing.stdout.json | 46 + .../result.json | 64 + .../complete-fixture/functions.arcadia | 31 + .../complete-fixture/project.arcadia | 4 + .../complete.stderr.log | 0 .../complete.stdout.json | 0 .../missing.stderr.log | 0 .../missing.stdout.json | 0 .../result.json | 60 + .../launch-manifest-final-1.json | 171 + .../native-preparation/launch.py | 89 + .../native-preparation/native-recorder.js | 31 + .../prepare-launch.before-artifact-binding.py | 48 + .../native-preparation/prepare-launch.py | 74 + .../native-preparation/prepare-oracle.py | 34 + .../native-preparation/trial-plan.md | 37 + .../preserved.json | 31 + .../trial-diagnostic-1/closed.json | 18 + .../trial-diagnostic-1/launch-manifest.json | 171 + .../trial-diagnostic-1/launch.json | 33 + .../screenshots/001-initial-home-0.jpg | Bin 0 -> 98586 bytes .../screenshots/001-initial-home.json | 24 + .../screenshots/002-open-dialog-0.jpg | Bin 0 -> 96945 bytes .../screenshots/002-open-dialog.json | 24 + .../screenshots/003-picker-0.jpg | Bin 0 -> 674 bytes .../screenshots/003-picker-1.jpg | Bin 0 -> 66926 bytes .../screenshots/003-picker.json | 33 + .../004-picker-filename-focus-0.jpg | Bin 0 -> 674 bytes .../004-picker-filename-focus-1.jpg | Bin 0 -> 67313 bytes .../004-picker-filename-focus.json | 33 + .../005-picker-visual-filename-focus-0.jpg | Bin 0 -> 674 bytes .../005-picker-visual-filename-focus-1.jpg | Bin 0 -> 67236 bytes .../005-picker-visual-filename-focus.json | 33 + .../screenshots/006-picker-fixture-path-0.jpg | Bin 0 -> 674 bytes .../screenshots/006-picker-fixture-path-1.jpg | Bin 0 -> 70663 bytes .../screenshots/006-picker-fixture-path.json | 33 + .../007-missing-include-open-0.jpg | Bin 0 -> 81284 bytes .../screenshots/007-missing-include-open.json | 24 + .../008-missing-include-pointer-probe-0.jpg | Bin 0 -> 82919 bytes .../008-missing-include-pointer-probe.json | 24 + .../009-missing-hover-settled-0.jpg | Bin 0 -> 82926 bytes .../009-missing-hover-settled.json | 24 + .../010-blank-space-clears-tooltip-0.jpg | Bin 0 -> 81863 bytes .../010-blank-space-clears-tooltip.json | 24 + .../screenshots/011-load-includes-0.jpg | Bin 0 -> 81623 bytes .../screenshots/011-load-includes.json | 24 + .../012-included-raw-warning-ranges-0.jpg | Bin 0 -> 133173 bytes .../012-included-raw-warning-ranges.json | 24 + .../013-included-overlap-blank-details-0.jpg | Bin 0 -> 129296 bytes .../013-included-overlap-blank-details.json | 24 + ...014-close-confirmation-hides-tooltip-0.jpg | Bin 0 -> 126017 bytes .../014-close-confirmation-hides-tooltip.json | 24 + .../015-modal-owns-marked-background-0.jpg | Bin 0 -> 126134 bytes .../015-modal-owns-marked-background.json | 24 + .../trial-diagnostic-1/stderr.log | 0 .../trial-diagnostic-1/stdout.log | 0 .../work-fixture/functions.arcadia | 31 + .../work-fixture/project.arcadia | 4 + .../notices/notices-actual-1/process.json | 18 + .../notices/notices-actual-1/run.ps1 | 70 + .../notices/notices-actual-1/stderr.log | 8 + .../notices/notices-actual-1/stdout.log | 0 .../notices/notices-actual-2/README.md | 20 + .../notices/notices-actual-2/inputs.json | 86 + .../notices/notices-actual-2/packages.json | 2662 +++++++++ .../notices/notices-actual-2/process.json | 18 + .../notices/notices-actual-2/result.json | 35 + .../notices/notices-actual-2/run.ps1 | 70 + .../notices/notices-actual-2/stderr.log | 0 .../notices/notices-actual-2/stdout.log | 1 + .../notices/notices-actual-2/vendor.json | 100 + .../notices-actual-2/verification.json | 11 + .../notices/scripts/test-windows-notices.ps1 | 185 + .../notices/scripts/windows-notices.ps1 | 211 + .../scripts/windows-notices/CC0-1.0.txt | 121 + .../Cantarell-Regular-NOTICE.txt | 31 + ...umanAsh-clipboard-win-3b27cf2bfd1a-LICENSE | 23 + .../scripts/windows-notices/Fira-OFL.txt | 93 + .../FiraSans-Regular-NOTICE.txt | 6 + .../GNOME-Cantarell-61362600e104-COPYING | 94 + .../windows-notices/Iced-Icons-NOTICE.txt | 33 + ...ysma-profiling-8271551172eb-LICENSE-APACHE | 201 + ...aclysma-profiling-8271551172eb-LICENSE-MIT | 21 + .../brendanzab-gl-rs-ea503e8d5fb6-LICENSE | 202 + .../brendanzab-gl-rs-f150967b1c44-LICENSE | 202 + .../gfx-rs-rspirv-e91a5debc10c-LICENSE | 202 + .../hexf-parse-0.2.1-Cargo.toml | 11 + .../iced-rs-iced-3997291f318a-LICENSE | 18 + .../iced-rs-iced-54abf81d13fe-LICENSE | 18 + .../llvm-ea7d852a70e8-libunwind-LICENSE.TXT | 311 + .../scripts/windows-notices/manifest.json | 436 ++ .../nical-lyon-56219c95c658-LICENSE-APACHE | 13 + .../nical-lyon-56219c95c658-LICENSE-MIT | 20 + .../nical-lyon-994526f2766c-LICENSE-APACHE | 13 + .../nical-lyon-994526f2766c-LICENSE-MIT | 20 + .../nical-rust_debug-93f414cd572b-LICENSE | 21 + .../zakarumych-gpu-alloc-61020bdcb4eb-COPYING | 6 + .../zakarumych-gpu-alloc-7ddd1fe576fc-COPYING | 6 + ...rumych-gpu-descriptor-9f2fb9546591-COPYING | 6 + ...rumych-gpu-descriptor-bb28b4e55b74-COPYING | 6 + .../notices/synthetic-1/result.json | 19 + .../notices/synthetic-1/stderr.log | 0 .../notices/synthetic-1/stdout.log | 3 + .../build-and-preserve-no-incremental.py | 35 + .../gate-no-incremental.py | 44 + .../probe/Cargo.lock | 3391 +++++++++++ .../probe/Cargo.toml | 16 + .../text-diagnostics-20260913/probe/README.md | 23 + .../probe/capture_inputs.py | 12 + .../probe/records/attempt-1/inputs.json | 310 + .../probe/records/attempt-1/result.json | 46 + .../probe/records/attempt-1/started.json | 26 + .../probe/records/attempt-1/stderr.log | 56 + .../probe/records/attempt-1/stdout.log | 7 + .../probe/records/prepared-inputs.json | 382 ++ .../probe/run_probe.py | 32 + .../probe/src/main.rs | 165 + .../probe/vendor-original-manifest.json | 352 ++ .../probe/vendor/iced_widget/.cargo-ok | 1 + .../vendor/iced_widget/.cargo_vcs_info.json | 6 + .../probe/vendor/iced_widget/Cargo.lock | 2224 +++++++ .../probe/vendor/iced_widget/Cargo.toml | 126 + .../probe/vendor/iced_widget/Cargo.toml.orig | 51 + .../vendor/iced_widget/assets/iced-logo.svg | 2 + .../probe/vendor/iced_widget/src/action.rs | 85 + .../probe/vendor/iced_widget/src/button.rs | 752 +++ .../probe/vendor/iced_widget/src/canvas.rs | 327 + .../vendor/iced_widget/src/canvas/program.rs | 111 + .../probe/vendor/iced_widget/src/checkbox.rs | 707 +++ .../probe/vendor/iced_widget/src/column.rs | 617 ++ .../probe/vendor/iced_widget/src/combo_box.rs | 1013 ++++ .../probe/vendor/iced_widget/src/container.rs | 660 +++ .../probe/vendor/iced_widget/src/float.rs | 432 ++ .../probe/vendor/iced_widget/src/grid.rs | 415 ++ .../probe/vendor/iced_widget/src/helpers.rs | 2107 +++++++ .../probe/vendor/iced_widget/src/image.rs | 418 ++ .../vendor/iced_widget/src/image/viewer.rs | 447 ++ .../probe/vendor/iced_widget/src/keyed.rs | 75 + .../vendor/iced_widget/src/keyed/column.rs | 398 ++ .../probe/vendor/iced_widget/src/lazy.rs | 417 ++ .../vendor/iced_widget/src/lazy/cache.rs | 14 + .../vendor/iced_widget/src/lazy/component.rs | 669 +++ .../vendor/iced_widget/src/lazy/helpers.rs | 43 + .../probe/vendor/iced_widget/src/lib.rs | 153 + .../probe/vendor/iced_widget/src/markdown.rs | 1722 ++++++ .../vendor/iced_widget/src/mouse_area.rs | 437 ++ .../probe/vendor/iced_widget/src/overlay.rs | 2 + .../vendor/iced_widget/src/overlay/menu.rs | 661 +++ .../probe/vendor/iced_widget/src/pane_grid.rs | 1343 +++++ .../vendor/iced_widget/src/pane_grid/axis.rs | 259 + .../src/pane_grid/configuration.rs | 26 + .../iced_widget/src/pane_grid/content.rs | 439 ++ .../iced_widget/src/pane_grid/controls.rs | 58 + .../iced_widget/src/pane_grid/direction.rs | 12 + .../iced_widget/src/pane_grid/draggable.rs | 8 + .../vendor/iced_widget/src/pane_grid/node.rs | 381 ++ .../vendor/iced_widget/src/pane_grid/pane.rs | 5 + .../vendor/iced_widget/src/pane_grid/split.rs | 5 + .../vendor/iced_widget/src/pane_grid/state.rs | 468 ++ .../iced_widget/src/pane_grid/title_bar.rs | 632 ++ .../probe/vendor/iced_widget/src/pick_list.rs | 929 +++ .../probe/vendor/iced_widget/src/pin.rs | 271 + .../vendor/iced_widget/src/progress_bar.rs | 333 ++ .../probe/vendor/iced_widget/src/qr_code.rs | 439 ++ .../probe/vendor/iced_widget/src/radio.rs | 551 ++ .../vendor/iced_widget/src/responsive.rs | 207 + .../probe/vendor/iced_widget/src/row.rs | 605 ++ .../probe/vendor/iced_widget/src/rule.rs | 325 + .../vendor/iced_widget/src/scrollable.rs | 2437 ++++++++ .../probe/vendor/iced_widget/src/sensor.rs | 447 ++ .../probe/vendor/iced_widget/src/shader.rs | 200 + .../vendor/iced_widget/src/shader/program.rs | 60 + .../probe/vendor/iced_widget/src/slider.rs | 702 +++ .../probe/vendor/iced_widget/src/space.rs | 104 + .../probe/vendor/iced_widget/src/stack.rs | 405 ++ .../probe/vendor/iced_widget/src/svg.rs | 340 ++ .../probe/vendor/iced_widget/src/table.rs | 725 +++ .../probe/vendor/iced_widget/src/text.rs | 30 + .../probe/vendor/iced_widget/src/text/rich.rs | 563 ++ .../vendor/iced_widget/src/text_editor.rs | 1534 +++++ .../vendor/iced_widget/src/text_input.rs | 1815 ++++++ .../iced_widget/src/text_input/cursor.rs | 189 + .../iced_widget/src/text_input/editor.rs | 70 + .../iced_widget/src/text_input/value.rs | 136 + .../probe/vendor/iced_widget/src/themer.rs | 296 + .../probe/vendor/iced_widget/src/toggler.rs | 613 ++ .../probe/vendor/iced_widget/src/tooltip.rs | 584 ++ .../vendor/iced_widget/src/vertical_slider.rs | 567 ++ .../lossless-compression-2-before.json | 5242 +++++++++++++++++ .../lossless-compression-2-result.json | 20 + .../lossless-compression-candidates-2.json | 119 + .../older-cache-compression-before.json | 5202 ++++++++++++++++ .../older-cache-compression-result.json | 16 + .../source-zip-reclaim-1.json | 74 + .../reviews/anchor-followup-review.md | 34 + .../reviews/annotation-index-review.md | 53 + .../annotation-performance-rationale.md | 20 + .../reviews/annotation-wrapper-review.md | 60 + .../core-provenance-result-review.json | 59 + .../reviews/core-provenance-result-review.md | 36 + .../current-oracle-preparation-review.md | 19 + .../diagnostic-provenance-repair-followup.md | 16 + .../reviews/diagnostic-provenance-repair.md | 44 + .../reviews/diagnostic-provenance-review.md | 39 + .../reviews/diagnostic-span-research.md | 75 + .../editor-geometry-probe-preparation.md | 7 + .../reviews/editor-geometry-research.md | 63 + .../final-annotation-geometry-review.md | 74 + .../reviews/final-source-delta-review.md | 38 + .../final-staged-integration-review.md | 34 + .../reviews/geometry-probe-result-review.md | 52 + .../reviews/geometry-seam-critique.md | 54 + ...launch-and-candidate-preparation-review.md | 13 + .../reviews/layout-test-intent.md | 9 + .../reviews/native-initial-review.json | 98 + .../reviews/native-initial-review.md | 11 + .../older-incremental-reclaim-review.json | 42 + .../performance-scroll-fixture-correction.md | 11 + .../reviews/prepared-anchor-followup.rs | 114 + .../prepared-annotation-performance.rs | 97 + .../reviews/prepared-widget-tests.rs | 538 ++ .../tooltip-lifetime-correction-review.md | 11 + .../reviews/vendor-capture-notices-review.md | 54 + .../reviews/vendor-notices-implementation.md | 30 + .../widget-newline-normalization.json | 18 + .../text-diagnostics-20260913/verify.py | 387 ++ ...-diagnostic-rendering-contract-20260913.md | 54 + harness/text-diagnostics-verified-20260913.md | 36 + scripts/test-windows-notices.ps1 | 63 + scripts/windows-notices.ps1 | 33 +- 480 files changed, 141982 insertions(+), 13 deletions(-) create mode 100644 crates/oa-desktop/src/source_annotations.rs create mode 100644 crates/oa-desktop/src/source_annotations/anchor_tests.rs create mode 100644 crates/oa-desktop/src/source_annotations/layout_tests.rs create mode 100644 crates/oa-desktop/src/source_annotations/performance_tests.rs create mode 100644 crates/oa-desktop/src/source_annotations/tests.rs create mode 100644 crates/oa-desktop/src/source_annotations/widget.rs create mode 100644 crates/oa-desktop/src/source_annotations/widget/tests.rs create mode 100644 crates/vendor/iced_widget/.cargo_vcs_info.json create mode 100644 crates/vendor/iced_widget/ARCADIA-PATCH.md create mode 100644 crates/vendor/iced_widget/Cargo.toml create mode 100644 crates/vendor/iced_widget/Cargo.toml.orig create mode 100644 crates/vendor/iced_widget/LICENSE create mode 100644 crates/vendor/iced_widget/UPSTREAM-MANIFEST.json create mode 100644 crates/vendor/iced_widget/assets/iced-logo.svg create mode 100644 crates/vendor/iced_widget/src/action.rs create mode 100644 crates/vendor/iced_widget/src/button.rs create mode 100644 crates/vendor/iced_widget/src/canvas.rs create mode 100644 crates/vendor/iced_widget/src/canvas/program.rs create mode 100644 crates/vendor/iced_widget/src/checkbox.rs create mode 100644 crates/vendor/iced_widget/src/column.rs create mode 100644 crates/vendor/iced_widget/src/combo_box.rs create mode 100644 crates/vendor/iced_widget/src/container.rs create mode 100644 crates/vendor/iced_widget/src/float.rs create mode 100644 crates/vendor/iced_widget/src/grid.rs create mode 100644 crates/vendor/iced_widget/src/helpers.rs create mode 100644 crates/vendor/iced_widget/src/image.rs create mode 100644 crates/vendor/iced_widget/src/image/viewer.rs create mode 100644 crates/vendor/iced_widget/src/keyed.rs create mode 100644 crates/vendor/iced_widget/src/keyed/column.rs create mode 100644 crates/vendor/iced_widget/src/lazy.rs create mode 100644 crates/vendor/iced_widget/src/lazy/cache.rs create mode 100644 crates/vendor/iced_widget/src/lazy/component.rs create mode 100644 crates/vendor/iced_widget/src/lazy/helpers.rs create mode 100644 crates/vendor/iced_widget/src/lib.rs create mode 100644 crates/vendor/iced_widget/src/markdown.rs create mode 100644 crates/vendor/iced_widget/src/mouse_area.rs create mode 100644 crates/vendor/iced_widget/src/overlay.rs create mode 100644 crates/vendor/iced_widget/src/overlay/menu.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid/axis.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid/configuration.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid/content.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid/controls.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid/direction.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid/draggable.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid/node.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid/pane.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid/split.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid/state.rs create mode 100644 crates/vendor/iced_widget/src/pane_grid/title_bar.rs create mode 100644 crates/vendor/iced_widget/src/pick_list.rs create mode 100644 crates/vendor/iced_widget/src/pin.rs create mode 100644 crates/vendor/iced_widget/src/progress_bar.rs create mode 100644 crates/vendor/iced_widget/src/qr_code.rs create mode 100644 crates/vendor/iced_widget/src/radio.rs create mode 100644 crates/vendor/iced_widget/src/responsive.rs create mode 100644 crates/vendor/iced_widget/src/row.rs create mode 100644 crates/vendor/iced_widget/src/rule.rs create mode 100644 crates/vendor/iced_widget/src/scrollable.rs create mode 100644 crates/vendor/iced_widget/src/sensor.rs create mode 100644 crates/vendor/iced_widget/src/shader.rs create mode 100644 crates/vendor/iced_widget/src/shader/program.rs create mode 100644 crates/vendor/iced_widget/src/slider.rs create mode 100644 crates/vendor/iced_widget/src/space.rs create mode 100644 crates/vendor/iced_widget/src/stack.rs create mode 100644 crates/vendor/iced_widget/src/svg.rs create mode 100644 crates/vendor/iced_widget/src/table.rs create mode 100644 crates/vendor/iced_widget/src/text.rs create mode 100644 crates/vendor/iced_widget/src/text/rich.rs create mode 100644 crates/vendor/iced_widget/src/text_editor.rs create mode 100644 crates/vendor/iced_widget/src/text_input.rs create mode 100644 crates/vendor/iced_widget/src/text_input/cursor.rs create mode 100644 crates/vendor/iced_widget/src/text_input/editor.rs create mode 100644 crates/vendor/iced_widget/src/text_input/value.rs create mode 100644 crates/vendor/iced_widget/src/themer.rs create mode 100644 crates/vendor/iced_widget/src/toggler.rs create mode 100644 crates/vendor/iced_widget/src/tooltip.rs create mode 100644 crates/vendor/iced_widget/src/vertical_slider.rs create mode 100644 harness/experiments/text-diagnostics-20260913/README.md create mode 100644 harness/experiments/text-diagnostics-20260913/captures/03c73f62ac5e8a276684ee4d4964f481e19fcb05c4edba9648167fdd9751da99/inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/captures/03c73f62ac5e8a276684ee4d4964f481e19fcb05c4edba9648167fdd9751da99/inputs.zip create mode 100644 harness/experiments/text-diagnostics-20260913/captures/5d3bf8cc37f518b0fef48c5270f1f506b865ac63c766ff6ba824a981141c60e9/inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/captures/5d3bf8cc37f518b0fef48c5270f1f506b865ac63c766ff6ba824a981141c60e9/inputs.zip create mode 100644 harness/experiments/text-diagnostics-20260913/captures/864a300c1779f863cf9cdf5b7fda902a78cfcd592274c4c6b745c64b2bc9347a/inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/captures/864a300c1779f863cf9cdf5b7fda902a78cfcd592274c4c6b745c64b2bc9347a/inputs.zip create mode 100644 harness/experiments/text-diagnostics-20260913/captures/8d15fab20876d3c8e899589f4e445f399930a63fae2ae5f5b0ed88db4a4a5169/inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/captures/8d15fab20876d3c8e899589f4e445f399930a63fae2ae5f5b0ed88db4a4a5169/inputs.zip create mode 100644 harness/experiments/text-diagnostics-20260913/captures/aa9ce581180fa6835b08d9c067c635f1d5ae925c8175042a74192b3d6f20d507/inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/captures/aa9ce581180fa6835b08d9c067c635f1d5ae925c8175042a74192b3d6f20d507/inputs.zip create mode 100644 harness/experiments/text-diagnostics-20260913/captures/b860a8fd4cb733540290faa4bcf5c805e207f23e595fe56e708199a3b4e82b84/inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/captures/b860a8fd4cb733540290faa4bcf5c805e207f23e595fe56e708199a3b4e82b84/inputs.zip create mode 100644 harness/experiments/text-diagnostics-20260913/captures/c5a6e697e1490a0be7af39c30b926a31e09a42e5a4120b40157d807eca7e7a70/inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/captures/c5a6e697e1490a0be7af39c30b926a31e09a42e5a4120b40157d807eca7e7a70/inputs.zip create mode 100644 harness/experiments/text-diagnostics-20260913/captures/d4d0df355e177ba1ac23df628bd0a5d0bbd8b4c3216909c73a0f1c38689ffe74/inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/captures/d4d0df355e177ba1ac23df628bd0a5d0bbd8b4c3216909c73a0f1c38689ffe74/inputs.zip create mode 100644 harness/experiments/text-diagnostics-20260913/captures/e3bf575df0961db3cd0bdea56122534ff888d7e6165c14b4cdfb8383ad31fbaa/inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/captures/e3bf575df0961db3cd0bdea56122534ff888d7e6165c14b4cdfb8383ad31fbaa/inputs.zip create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/closed-binary-reclaim-candidates-2-superseded-draft.json create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/closed-binary-reclaim-candidates-2.json create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/closed-binary-reclaim-candidates-2.md create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/closed-binary-reclaim-candidates-3.json create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/closed-binary-reclaim-candidates-3.md create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/closed-binary-reclaim-candidates.json create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/closed-binary-reclaim-candidates.md create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/closed-binary-reclaim-completed-2.json create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/closed-binary-reclaim-completed-3.json create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/closed-binary-reclaim-completed.json create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/closed-duplicate-binary-cleanup.json create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/reclaim-process-readback-2.json create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/reclaim-process-readback-3.json create mode 100644 harness/experiments/text-diagnostics-20260913/cleanup/reclaim-process-readback.json create mode 100644 harness/experiments/text-diagnostics-20260913/copy-record.json create mode 100644 harness/experiments/text-diagnostics-20260913/environment/env.ps1 create mode 100644 harness/experiments/text-diagnostics-20260913/files.json create mode 100644 harness/experiments/text-diagnostics-20260913/gate-helpers/adopted-workspace-metadata.json create mode 100644 harness/experiments/text-diagnostics-20260913/gate-helpers/gate.py create mode 100644 harness/experiments/text-diagnostics-20260913/gate-helpers/install-prepared-tests.py create mode 100644 harness/experiments/text-diagnostics-20260913/gate-helpers/prepared-app-annotation-tests.rs create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-admission-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-admission-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-admission-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-admission-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-attempt-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-attempt-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-attempt-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-attempt-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-corrected-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-corrected-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-corrected-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-corrected-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-manual-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-manual-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-manual-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-manual-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-projection-cost-2/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-projection-cost-2/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-projection-cost-2/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/annotation-projection-cost-2/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-cli-final-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-cli-final-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-cli-final-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-cli-final-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-cli-final-2/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-cli-final-2/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-cli-final-2/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-cli-final-2/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-full-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-full-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-full-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/desktop-full-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-2/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-2/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-2/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-2/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-3/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-3/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-3/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/format-final-3/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/mounted-annotation-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/mounted-annotation-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/mounted-annotation-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/mounted-annotation-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/provenance-app-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/provenance-app-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/provenance-app-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/provenance-app-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/provenance-core-full-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/provenance-core-full-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/provenance-core-full-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/provenance-core-full-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-2/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-2/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-2/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-2/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-3/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-3/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-3/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-3/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-4/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-4/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-4/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-4/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-5/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-5/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-5/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/strict-clippy-5/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/tooltip-lifetime-focused-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/tooltip-lifetime-focused-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/tooltip-lifetime-focused-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/tooltip-lifetime-focused-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/windows-build-final-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/windows-build-final-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/windows-build-final-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/windows-build-final-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/windows-build-final-2/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/windows-build-final-2/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/gates/windows-build-final-2/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/gates/windows-build-final-2/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/index.json create mode 100644 harness/experiments/text-diagnostics-20260913/licenses/LICENSE create mode 100644 harness/experiments/text-diagnostics-20260913/licenses/iced-upstream-54abf81-LICENSE create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/build-and-preserve.py create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/candidate-oracle-1/eof.stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/candidate-oracle-1/eof.stdout.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/candidate-oracle-1/eof/functions.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/candidate-oracle-1/eof/project.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/candidate-oracle-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/candidate-oracle-1/token.stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/candidate-oracle-1/token.stdout.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/candidate-oracle-1/token/functions.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/candidate-oracle-1/token/project.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/candidate-oracle.py create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/current-oracle-1/complete.stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/current-oracle-1/complete.stdout.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/current-oracle-1/complete/functions.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/current-oracle-1/complete/project.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/current-oracle-1/missing.stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/current-oracle-1/missing.stdout.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/current-oracle-1/missing/project.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/current-oracle-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/current-oracle.py create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/fixtures/functions-to-discover.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/fixtures/missing-include/project.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight-2/README.md create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight-2/complete-fixture/functions.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight-2/complete-fixture/project.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight-2/complete.stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight-2/complete.stdout.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight-2/missing.stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight-2/missing.stdout.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight-2/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight/complete-fixture/functions.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight/complete-fixture/project.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight/complete.stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight/complete.stdout.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight/missing.stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight/missing.stdout.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/historical-cli-fixture-preflight/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/launch-manifest-final-1.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/launch.py create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/native-recorder.js create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/prepare-launch.before-artifact-binding.py create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/prepare-launch.py create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/prepare-oracle.py create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/trial-plan.md create mode 100644 harness/experiments/text-diagnostics-20260913/native-preparation/windows-build-final-1-binaries/preserved.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/closed.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/launch-manifest.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/launch.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/001-initial-home-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/001-initial-home.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/002-open-dialog-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/002-open-dialog.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/003-picker-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/003-picker-1.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/003-picker.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/004-picker-filename-focus-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/004-picker-filename-focus-1.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/004-picker-filename-focus.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/005-picker-visual-filename-focus-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/005-picker-visual-filename-focus-1.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/005-picker-visual-filename-focus.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/006-picker-fixture-path-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/006-picker-fixture-path-1.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/006-picker-fixture-path.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/007-missing-include-open-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/007-missing-include-open.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/008-missing-include-pointer-probe-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/008-missing-include-pointer-probe.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/009-missing-hover-settled-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/009-missing-hover-settled.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/010-blank-space-clears-tooltip-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/010-blank-space-clears-tooltip.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/011-load-includes-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/011-load-includes.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/012-included-raw-warning-ranges-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/012-included-raw-warning-ranges.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/013-included-overlap-blank-details-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/013-included-overlap-blank-details.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/014-close-confirmation-hides-tooltip-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/014-close-confirmation-hides-tooltip.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/015-modal-owns-marked-background-0.jpg create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/screenshots/015-modal-owns-marked-background.json create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/work-fixture/functions.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/native-trials/trial-diagnostic-1/work-fixture/project.arcadia create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-1/process.json create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-1/run.ps1 create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-2/README.md create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-2/inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-2/packages.json create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-2/process.json create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-2/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-2/run.ps1 create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-2/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-2/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-2/vendor.json create mode 100644 harness/experiments/text-diagnostics-20260913/notices/notices-actual-2/verification.json create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/test-windows-notices.ps1 create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices.ps1 create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/CC0-1.0.txt create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/Cantarell-Regular-NOTICE.txt create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/DoumanAsh-clipboard-win-3b27cf2bfd1a-LICENSE create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/Fira-OFL.txt create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/FiraSans-Regular-NOTICE.txt create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/Iced-Icons-NOTICE.txt create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/aclysma-profiling-8271551172eb-LICENSE-APACHE create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/aclysma-profiling-8271551172eb-LICENSE-MIT create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/brendanzab-gl-rs-ea503e8d5fb6-LICENSE create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/brendanzab-gl-rs-f150967b1c44-LICENSE create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/gfx-rs-rspirv-e91a5debc10c-LICENSE create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/hexf-parse-0.2.1-Cargo.toml create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/iced-rs-iced-3997291f318a-LICENSE create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/iced-rs-iced-54abf81d13fe-LICENSE create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/llvm-ea7d852a70e8-libunwind-LICENSE.TXT create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/manifest.json create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/nical-lyon-56219c95c658-LICENSE-APACHE create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/nical-lyon-56219c95c658-LICENSE-MIT create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/nical-lyon-994526f2766c-LICENSE-APACHE create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/nical-lyon-994526f2766c-LICENSE-MIT create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/nical-rust_debug-93f414cd572b-LICENSE create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/zakarumych-gpu-alloc-61020bdcb4eb-COPYING create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/zakarumych-gpu-alloc-7ddd1fe576fc-COPYING create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/zakarumych-gpu-descriptor-9f2fb9546591-COPYING create mode 100644 harness/experiments/text-diagnostics-20260913/notices/scripts/windows-notices/zakarumych-gpu-descriptor-bb28b4e55b74-COPYING create mode 100644 harness/experiments/text-diagnostics-20260913/notices/synthetic-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/notices/synthetic-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/notices/synthetic-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/prepared-recovery-helpers/build-and-preserve-no-incremental.py create mode 100644 harness/experiments/text-diagnostics-20260913/prepared-recovery-helpers/gate-no-incremental.py create mode 100644 harness/experiments/text-diagnostics-20260913/probe/Cargo.lock create mode 100644 harness/experiments/text-diagnostics-20260913/probe/Cargo.toml create mode 100644 harness/experiments/text-diagnostics-20260913/probe/README.md create mode 100644 harness/experiments/text-diagnostics-20260913/probe/capture_inputs.py create mode 100644 harness/experiments/text-diagnostics-20260913/probe/records/attempt-1/inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/probe/records/attempt-1/result.json create mode 100644 harness/experiments/text-diagnostics-20260913/probe/records/attempt-1/started.json create mode 100644 harness/experiments/text-diagnostics-20260913/probe/records/attempt-1/stderr.log create mode 100644 harness/experiments/text-diagnostics-20260913/probe/records/attempt-1/stdout.log create mode 100644 harness/experiments/text-diagnostics-20260913/probe/records/prepared-inputs.json create mode 100644 harness/experiments/text-diagnostics-20260913/probe/run_probe.py create mode 100644 harness/experiments/text-diagnostics-20260913/probe/src/main.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor-original-manifest.json create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/.cargo-ok create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/.cargo_vcs_info.json create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/Cargo.lock create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/Cargo.toml create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/Cargo.toml.orig create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/assets/iced-logo.svg create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/action.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/button.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/canvas.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/canvas/program.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/checkbox.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/column.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/combo_box.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/container.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/float.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/grid.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/helpers.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/image.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/image/viewer.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/keyed.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/keyed/column.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/lazy.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/lazy/cache.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/lazy/component.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/lazy/helpers.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/lib.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/markdown.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/mouse_area.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/overlay.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/overlay/menu.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid/axis.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid/configuration.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid/content.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid/controls.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid/direction.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid/draggable.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid/node.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid/pane.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid/split.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid/state.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pane_grid/title_bar.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pick_list.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/pin.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/progress_bar.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/qr_code.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/radio.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/responsive.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/row.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/rule.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/scrollable.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/sensor.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/shader.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/shader/program.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/slider.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/space.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/stack.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/svg.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/table.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/text.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/text/rich.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/text_editor.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/text_input.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/text_input/cursor.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/text_input/editor.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/text_input/value.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/themer.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/toggler.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/tooltip.rs create mode 100644 harness/experiments/text-diagnostics-20260913/probe/vendor/iced_widget/src/vertical_slider.rs create mode 100644 harness/experiments/text-diagnostics-20260913/resource-recovery/lossless-compression-2-before.json create mode 100644 harness/experiments/text-diagnostics-20260913/resource-recovery/lossless-compression-2-result.json create mode 100644 harness/experiments/text-diagnostics-20260913/resource-recovery/lossless-compression-candidates-2.json create mode 100644 harness/experiments/text-diagnostics-20260913/resource-recovery/older-cache-compression-before.json create mode 100644 harness/experiments/text-diagnostics-20260913/resource-recovery/older-cache-compression-result.json create mode 100644 harness/experiments/text-diagnostics-20260913/resource-recovery/source-zip-reclaim-1.json create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/anchor-followup-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/annotation-index-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/annotation-performance-rationale.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/annotation-wrapper-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/core-provenance-result-review.json create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/core-provenance-result-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/current-oracle-preparation-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/diagnostic-provenance-repair-followup.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/diagnostic-provenance-repair.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/diagnostic-provenance-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/diagnostic-span-research.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/editor-geometry-probe-preparation.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/editor-geometry-research.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/final-annotation-geometry-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/final-source-delta-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/final-staged-integration-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/geometry-probe-result-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/geometry-seam-critique.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/launch-and-candidate-preparation-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/layout-test-intent.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/native-initial-review.json create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/native-initial-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/older-incremental-reclaim-review.json create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/performance-scroll-fixture-correction.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/prepared-anchor-followup.rs create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/prepared-annotation-performance.rs create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/prepared-widget-tests.rs create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/tooltip-lifetime-correction-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/vendor-capture-notices-review.md create mode 100644 harness/experiments/text-diagnostics-20260913/reviews/vendor-notices-implementation.md create mode 100644 harness/experiments/text-diagnostics-20260913/source-normalization/widget-newline-normalization.json create mode 100644 harness/experiments/text-diagnostics-20260913/verify.py create mode 100644 harness/text-diagnostic-rendering-contract-20260913.md create mode 100644 harness/text-diagnostics-verified-20260913.md diff --git a/.gitattributes b/.gitattributes index 00e61b66..49a8fdda 100644 --- a/.gitattributes +++ b/.gitattributes @@ -89,3 +89,7 @@ harness/experiments/text-find-selection-20260913/**/*.log -whitespace harness/experiments/text-workspace-initial-20260913/**/*.log -whitespace harness/experiments/text-workspace-native2-20260913/**/*.log -whitespace harness/experiments/text-workspace-native3-20260913/**/*.log -whitespace +# Preserve the pinned dependency and diagnostic verification records verbatim. +crates/vendor/iced_widget/** -text whitespace=cr-at-eol +harness/experiments/text-diagnostics-20260913/** -text whitespace=cr-at-eol +harness/experiments/text-diagnostics-20260913/**/*.log -whitespace diff --git a/Cargo.lock b/Cargo.lock index b3bda78b..49699e4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1567,8 +1567,6 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" dependencies = [ "iced_renderer", "log", @@ -2176,6 +2174,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "unicode-segmentation", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index db71c553..535841de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,11 @@ [workspace] members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"] +exclude = ["crates/vendor/iced_widget"] resolver = "2" +[patch.crates-io] +iced_widget = { path = "crates/vendor/iced_widget" } + [workspace.package] version = "0.2.0" edition = "2024" diff --git a/crates/oa-cli/src/lib.rs b/crates/oa-cli/src/lib.rs index d7b71537..0eaced56 100644 --- a/crates/oa-cli/src/lib.rs +++ b/crates/oa-cli/src/lib.rs @@ -222,8 +222,8 @@ pub fn prepare(entry: &Path, operation: Operation) -> Result = snapshot .diagnostics .iter() diff --git a/crates/oa-core/src/source_files.rs b/crates/oa-core/src/source_files.rs index 524ec44e..319416b5 100644 --- a/crates/oa-core/src/source_files.rs +++ b/crates/oa-core/src/source_files.rs @@ -40,6 +40,7 @@ pub enum DiskExpectation { /// `bundle` retains detached and invalid buffers. Compile `reachable_bundle()`; /// use `diagnostics` to explain files that discovery could not read or parse. +/// Diagnostic spans describe `bundle` bytes, including before UUID stabilization. /// Expectations always describe disk bytes before UUID stabilization. #[derive(Debug, Clone)] pub struct DiskProject { @@ -526,10 +527,11 @@ pub fn discover_missing( result.expectations.keys().chain(result.bundle.files.keys()), )?; if result.diagnostics.is_empty() { - match compile_bundle(&result.reachable_bundle(), limits) { - Ok(compiled) => result.diagnostics = compiled.diagnostics, - Err(diagnostics) => result.diagnostics = diagnostics, - } + // Discovery retains raw editor bytes. Compilation may insert UUIDs or + // rewrite older syntax, so its returned locations belong to a different + // bundle. Lint still performs semantic migration and model validation; + // Open/Apply separately compile and install the rewritten source. + result.diagnostics = lint_bundle(&result.reachable_bundle(), limits); } let pending = journal_path(&result.root, &result.bundle.entry); if fs::symlink_metadata(&pending).is_ok() { diff --git a/crates/oa-core/tests/source_files.rs b/crates/oa-core/tests/source_files.rs index 8749684a..0c62458b 100644 --- a/crates/oa-core/tests/source_files.rs +++ b/crates/oa-core/tests/source_files.rs @@ -383,3 +383,87 @@ fn atomic_save_preserves_existing_unix_permissions() { 0o640 ); } + +#[test] +fn discovery_diagnostics_keep_raw_no_uuid_spans() { + let directory = setup(); + let path = directory.path().join("parts/a.arcadia"); + let raw = + "// Unicode: 航海\nsa_function first \"First\" {}\nsa_function second \"Second\" {}\n"; + fs::write(&path, raw).unwrap(); + let disk = load_project( + &directory.path().join("project.arcadia"), + ProjectLimits::default(), + ) + .unwrap(); + assert_eq!(disk.bundle.files["parts/a.arcadia"], raw); + let issue = disk + .diagnostics + .iter() + .find(|issue| { + issue.diagnostic.code == "unallocated_leaf" + && issue.diagnostic.message.contains("Second") + }) + .unwrap(); + assert_eq!(issue.path, "parts/a.arcadia"); + assert_eq!(issue.diagnostic.line, 3); + assert_eq!(issue.diagnostic.column, 1); + let span = issue.diagnostic.span; + assert_eq!(span.start, raw.find("sa_function second").unwrap()); + assert_eq!( + &raw[span.start..span.end], + "sa_function second \"Second\" {}" + ); + let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap(); + assert_ne!(compiled.bundle, disk.bundle); + assert!(compiled.diagnostics.iter().any(|later| { + later.diagnostic.code == issue.diagnostic.code + && later.diagnostic.message == issue.diagnostic.message + && later.diagnostic.span.start > span.start + })); + assert_eq!(fs::read_to_string(path).unwrap(), raw); +} + +#[test] +fn discovery_diagnoses_legacy_semantics_before_source_rewrite() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("project.arcadia"); + let raw = include_str!("fixtures/v3-data-contract.arcadia"); + fs::write(&path, raw).unwrap(); + let disk = load_project(&path, ProjectLimits::default()).unwrap(); + assert_eq!(disk.bundle.files["project.arcadia"], raw); + assert!(!disk.diagnostics.is_empty()); + assert!( + disk.diagnostics + .iter() + .all(|issue| { issue.diagnostic.severity == oa_core::validation::Severity::Warning }) + ); + assert_eq!( + disk.diagnostics, + lint_bundle(&disk.reachable_bundle(), ProjectLimits::default()) + ); + let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap(); + assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3); + assert_ne!(compiled.bundle, disk.bundle); + for issue in &disk.diagnostics { + assert!(compiled.diagnostics.iter().any(|later| { + later.diagnostic.code == issue.diagnostic.code + && later.diagnostic.object_id == issue.diagnostic.object_id + && later.diagnostic.message == issue.diagnostic.message + })); + let span = issue.diagnostic.span; + assert!(raw.get(span.start..span.end).is_some()); + } + // Invalid legacy references still fail during semantic resolution on the + // authored bytes; deferring the source rewrite does not hide those errors. + let invalid = raw.replace("@coordinate", "@missing"); + fs::write(&path, &invalid).unwrap(); + let broken = load_project(&path, ProjectLimits::default()).unwrap(); + assert!(broken.diagnostics.iter().any(|issue| { + issue.diagnostic.severity == oa_core::validation::Severity::Error + && issue.diagnostic.message.contains("@missing") + })); + assert_eq!(broken.bundle.files["project.arcadia"], invalid); + assert!(compile_bundle(&broken.reachable_bundle(), ProjectLimits::default()).is_err()); + assert_eq!(fs::read_to_string(path).unwrap(), invalid); +} diff --git a/crates/oa-desktop/Cargo.toml b/crates/oa-desktop/Cargo.toml index 2eed9a7c..13f5f911 100644 --- a/crates/oa-desktop/Cargo.toml +++ b/crates/oa-desktop/Cargo.toml @@ -18,6 +18,7 @@ serde_json.workspace = true reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1", features = ["rt", "time"] } tempfile = "3" +unicode-segmentation = "1.13.3" [dev-dependencies] iced_runtime = "0.14" diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 2a84871c..9ad68c40 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -3106,6 +3106,17 @@ impl App { if version == self.source_version { match result { Ok(disk) => { + // Discovery must include the exact overlays retained + // by this workspace. A current request version alone + // does not prove arbitrary reply spans match them. + if disk.bundle.entry != self.source_workspace.entry + || self.source_workspace.files.iter().any(|(path, buffer)| { + disk.bundle.files.get(path) != Some(&buffer.text) + }) + { + self.status = "Included-file results no longer match the retained drafts. Load includes again.".into(); + return self.request_lint(); + } if let Some(target) = recovery_target(&disk) { self.pending_recovery = Some(target); } @@ -4992,6 +5003,275 @@ mod source_project_tests { ); assert!(!app.dirty()); } + fn open_missing_function_file(directory: &Directory) -> App { + let entry = format!( + "arcadia {}; project p \"P\" {{ include \"work.arcadia\"; }}\n", + oa_core::SCHEMA_VERSION + ); + let path = directory.0.join("project.arcadia"); + std::fs::write(&path, entry).unwrap(); + let mut app = App::with_project(Project::demo()); + send( + &mut app, + Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))), + ); + assert!(!app.source_workspace.applied); + assert!(!app.source_workspace.files.contains_key("work.arcadia")); + app + } + + fn assert_raw_second_warning(app: &App, raw: &str) { + let issue = app + .source_diagnostics + .iter() + .find(|issue| { + issue.path == "work.arcadia" + && issue.diagnostic.code == "unallocated_leaf" + && issue.diagnostic.message.contains("Second") + }) + .unwrap(); + let span = issue.diagnostic.span; + assert_eq!(span.start, raw.find("sa_function second").unwrap()); + assert_eq!( + &raw[span.start..span.end], + "sa_function second \"Second\" {}" + ); + assert_eq!( + app.text_workspace + .diagnostic_publication() + .unwrap() + .source(), + app.source_key() + ); + } + + #[test] + fn includes_loaded_diagnostics_use_raw_newly_discovered_bytes() { + let directory = Directory::new(); + let mut app = open_missing_function_file(&directory); + let model = app.document.snapshot(); + let publication = app.text_workspace.diagnostic_publication(); + let raw = "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n"; + std::fs::write(directory.0.join("work.arcadia"), raw).unwrap(); + let disk = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + // Real producer control: compiled diagnostics would refer to inserted + // IDs, while IncludesLoaded must leave the raw draft unapplied. + let compiled = source_project::compile_bundle(&disk.reachable_bundle(), LIMITS).unwrap(); + assert_ne!(compiled.bundle, disk.bundle); + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(disk))); + assert_raw_second_warning(&app, raw); + assert_eq!(app.source_workspace.files["work.arcadia"].text, raw); + assert!(!app.source_workspace.applied); + assert!(Arc::ptr_eq(&model, &app.document.snapshot())); + assert_ne!(app.text_workspace.diagnostic_publication(), publication); + assert_eq!( + std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(), + raw + ); + } + + #[test] + fn includes_loaded_diagnostics_use_retained_overlay_and_ignore_old_version() { + let directory = Directory::new(); + let mut app = open_missing_function_file(&directory); + let raw = "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n"; + std::fs::write(directory.0.join("work.arcadia"), raw).unwrap(); + let first = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(first))); + let model = app.document.snapshot(); + let previous = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + let previous_version = app.source_version; + send(&mut app, Message::SourceFile("work.arcadia".into())); + let edited = format!("// Retained Unicode draft: 航海\n{raw}"); + edit(&mut app, &edited); + let current = source_workspace::lint(&app.source_workspace.bundle()); + app.publish_source_diagnostics(current); + let publication = app.text_workspace.diagnostic_publication(); + let key = app.source_key(); + send( + &mut app, + Message::IncludesLoaded(previous_version, Ok(previous)), + ); + assert_eq!(app.source_key(), key); + assert_eq!(app.text_workspace.diagnostic_publication(), publication); + assert_raw_second_warning(&app, &edited); + + std::fs::write(directory.0.join("work.arcadia"), "// external disk edit\n").unwrap(); + let discovered = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + assert_eq!(discovered.bundle.files["work.arcadia"], edited); + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(discovered))); + assert_raw_second_warning(&app, &edited); + assert_ne!(app.text_workspace.diagnostic_publication(), publication); + assert_eq!(app.source_editor.text(), edited); + assert_eq!(app.source_workspace.files["work.arcadia"].text, edited); + assert!(Arc::ptr_eq(&model, &app.document.snapshot())); + assert_eq!( + std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(), + "// external disk edit\n" + ); + } + + #[test] + fn includes_loaded_rejects_mismatched_retained_bytes_before_publication() { + let directory = Directory::new(); + let mut app = open_missing_function_file(&directory); + std::fs::write( + directory.0.join("work.arcadia"), + "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n", + ) + .unwrap(); + // Discovery can also report a pending recovery target. A rejected + // reply must not install that metadata either. + std::fs::create_dir(directory.0.join(".arcadia-save-project.arcadia.journal")).unwrap(); + let reply = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + assert!(recovery_target(&reply).is_some()); + let edited = format!("// retained entry draft\n{}", app.source_editor.text()); + edit(&mut app, &edited); + let current = source_workspace::lint(&app.source_workspace.bundle()); + app.publish_source_diagnostics(current); + assert!(!app.source_diagnostics.is_empty()); + let publication = app.text_workspace.diagnostic_publication(); + let diagnostics = app.source_diagnostics.clone(); + let before = app.source_workspace.bundle(); + let key = app.source_key(); + let reachable = app.source_workspace.reachable.clone(); + let disk = app.disk_project.clone().unwrap(); + let model = app.document.snapshot(); + let recovery = app.pending_recovery.clone(); + // Deliberately stamp an older real producer reply with the current + // version: this tests byte admission independently of version refusal. + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(reply))); + assert_eq!(app.source_version, version); + assert_eq!(app.source_key(), key); + assert_eq!(app.source_workspace.bundle(), before); + assert_eq!(app.source_workspace.reachable, reachable); + assert!(!app.source_workspace.files.contains_key("work.arcadia")); + assert_eq!(app.source_editor.text(), edited); + assert_eq!(app.pending_recovery, recovery); + assert_eq!(app.text_workspace.diagnostic_publication(), publication); + assert_eq!(app.source_diagnostics, diagnostics); + let after = app.disk_project.as_ref().unwrap(); + assert_eq!(after.root, disk.root); + assert_eq!(after.bundle, disk.bundle); + assert_eq!(after.expectations, disk.expectations); + assert_eq!(after.reachable, disk.reachable); + assert_eq!(after.diagnostics, disk.diagnostics); + assert!(Arc::ptr_eq(&model, &app.document.snapshot())); + } + + #[test] + fn includes_loaded_requires_each_inactive_detached_overlay() { + let directory = Directory::new(); + let mut app = open_missing_function_file(&directory); + let original = "sa_function first \"First\" {}\n"; + std::fs::write(directory.0.join("work.arcadia"), original).unwrap(); + let discovered = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(discovered))); + send(&mut app, Message::SourceFile("work.arcadia".into())); + let retained = format!("// retained detached draft\n{original}"); + edit(&mut app, &retained); + send(&mut app, Message::SourceFile("project.arcadia".into())); + let entry = format!( + "arcadia {}; project p \"P\" {{}}\n", + oa_core::SCHEMA_VERSION + ); + edit(&mut app, &entry); + assert!(app.apply_source(), "{}", app.status); + assert_eq!(app.source_workspace.active, app.source_workspace.entry); + assert!(!app.source_workspace.reachable.contains("work.arcadia")); + assert_eq!(app.source_workspace.files["work.arcadia"].text, retained); + let valid = source_files::discover_missing( + app.disk_project.as_ref().unwrap(), + &app.source_workspace.bundle().files, + LIMITS, + ) + .unwrap(); + let before = app.source_workspace.bundle(); + let key = app.source_key(); + let publication = app.text_workspace.diagnostic_publication(); + let diagnostics = app.source_diagnostics.clone(); + let disk = app.disk_project.clone().unwrap(); + let model = app.document.snapshot(); + for missing in [false, true] { + let mut reply = valid.clone(); + if missing { + reply.bundle.files.remove("work.arcadia"); + } else { + reply + .bundle + .files + .insert("work.arcadia".into(), original.into()); + } + // Entry and active text match exactly. Only an inactive, detached + // overlay is wrong or absent; checking active/entry alone would pass. + assert_eq!( + reply.bundle.files[&app.source_workspace.entry], + app.source_editor.text() + ); + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(reply))); + assert_eq!(app.source_version, version); + assert_eq!(app.source_key(), key); + assert_eq!(app.source_workspace.bundle(), before); + assert!(!app.source_workspace.reachable.contains("work.arcadia")); + assert_eq!(app.text_workspace.diagnostic_publication(), publication); + assert_eq!(app.source_diagnostics, diagnostics); + let after = app.disk_project.as_ref().unwrap(); + assert_eq!(after.bundle, disk.bundle); + assert_eq!(after.expectations, disk.expectations); + assert_eq!(after.reachable, disk.reachable); + assert_eq!(after.diagnostics, disk.diagnostics); + assert!(Arc::ptr_eq(&model, &app.document.snapshot())); + } + // Exact overlays remain admissible even when the retained draft is + // detached and its external file still contains the original bytes. + let version = app.source_version; + send(&mut app, Message::IncludesLoaded(version, Ok(valid))); + assert_eq!(app.source_version, version + 1); + assert_ne!(app.text_workspace.diagnostic_publication(), publication); + assert_eq!(app.source_workspace.bundle(), before); + assert_eq!(app.source_workspace.files["work.arcadia"].text, retained); + assert_eq!( + std::fs::read_to_string(directory.0.join("work.arcadia")).unwrap(), + original + ); + } + #[test] fn closed_detached_file_keeps_first_save_conflict_then_can_be_reloaded_fresh() { let directory = Directory::new(); diff --git a/crates/oa-desktop/src/app/text_workspace.rs b/crates/oa-desktop/src/app/text_workspace.rs index 3b0c13b1..377cf18d 100644 --- a/crates/oa-desktop/src/app/text_workspace.rs +++ b/crates/oa-desktop/src/app/text_workspace.rs @@ -384,6 +384,24 @@ impl App { )) }) } + pub(crate) fn source_annotation_snapshot( + &self, + ) -> Option> { + let publication = self.text_workspace.diagnostic_publication?; + let buffer = self + .source_workspace + .files + .get(&self.source_workspace.active)?; + if publication.source != self.source_key() || buffer.text != self.source_snapshot.as_ref() { + return None; + } + Some(crate::source_annotations::Snapshot { + publication: publication.generation, + source: self.source_snapshot.clone(), + path: &self.source_workspace.active, + diagnostics: &self.source_diagnostics, + }) + } fn text_command_action(&self, command: Command) -> Option { // Menu has been taken before dispatch-time admission. This intentionally // uses navigation admission, not rename admission, for Find/text history. diff --git a/crates/oa-desktop/src/app/text_workspace/tests.rs b/crates/oa-desktop/src/app/text_workspace/tests.rs index 6de90672..70d62254 100644 --- a/crates/oa-desktop/src/app/text_workspace/tests.rs +++ b/crates/oa-desktop/src/app/text_workspace/tests.rs @@ -615,3 +615,78 @@ fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() { assert!(!app.document.can_undo() && !app.document.can_redo()); } } + +#[test] +fn annotation_snapshot_requires_exact_current_bytes_and_publication() { + let mut app = fixture(); + let original_model = app.document.snapshot(); + let original_source = app.source_snapshot.clone(); + let publication = app.source_annotation_snapshot().unwrap().publication; + let initial = app.source_annotation_snapshot().unwrap(); + assert!(Arc::ptr_eq(&initial.source, &original_source)); + assert_eq!(initial.path, app.source_workspace.active); + assert!(std::ptr::eq( + initial.diagnostics, + app.source_diagnostics.as_slice() + )); + + // Republish identical diagnostics: old cached geometry must get a new identity. + app.publish_source_diagnostics(app.source_diagnostics.clone()); + assert_ne!( + app.source_annotation_snapshot().unwrap().publication, + publication + ); + + app.source_workspace + .edit("// newer draft\n".into(), app.source_editor.cursor()); + assert!( + app.source_annotation_snapshot().is_none(), + "old publication after a real draft edit" + ); + // A current source key alone must not bind new ranges to the old displayed text. + app.publish_source_diagnostics(Vec::new()); + assert!( + app.source_annotation_snapshot().is_none(), + "displayed snapshot still contains old bytes" + ); + app.show_source_file(false); + let current = app.source_annotation_snapshot().unwrap(); + assert_eq!(current.source.as_ref(), "// newer draft\n"); + assert!(current.diagnostics.is_empty()); + assert!(Arc::ptr_eq(&original_model, &app.document.snapshot())); + app.text_workspace.reset(); + assert!(app.source_annotation_snapshot().is_none()); +} + +#[test] +fn annotation_snapshot_tracks_real_file_round_trips_without_changing_model() { + let mut project = Project::empty(); + let element = project + .add_element( + oa_core::Kind::Activity, + oa_core::Layer::Oa, + "Assess incident", + None, + ) + .unwrap(); + let mut app = App::with_project(project); + app.page = Page::Text; + app.publish_source_diagnostics(app.source_diagnostics.clone()); + let model = app.document.snapshot(); + let entry = app.source_workspace.entry.clone(); + let fragment = app.document.compiled().index.declarations[&element] + .path + .clone(); + assert_ne!(entry, fragment); + for path in [&entry, &fragment, &entry] { + let _ = app.update(Message::SourceFile(path.clone())); + let current = app.source_annotation_snapshot().unwrap(); + assert_eq!(current.path, path); + assert_eq!( + current.source.as_ref(), + app.source_workspace.files[path].text + ); + assert!(Arc::ptr_eq(¤t.source, &app.source_snapshot)); + assert!(Arc::ptr_eq(&model, &app.document.snapshot())); + } +} diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index 85da746e..a58dd492 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -24,6 +24,7 @@ mod port_workflow; mod recovery; mod route_edit; mod semantic_review; +mod source_annotations; mod source_navigation; mod source_rename; mod source_tree; diff --git a/crates/oa-desktop/src/source_annotations.rs b/crates/oa-desktop/src/source_annotations.rs new file mode 100644 index 00000000..965090e3 --- /dev/null +++ b/crates/oa-desktop/src/source_annotations.rs @@ -0,0 +1,309 @@ +//! Read-only source-range admission and projection onto the editor's real layout. +//! Byte ranges retain their diagnostic meaning; visual bands expand to clusters. +mod widget; +use iced::{Point, Rectangle, Size, Vector, advanced::graphics::text::cosmic_text}; +use oa_core::source_project::SourceDiagnostic; +use std::{collections::BTreeMap, ops::Range, sync::Arc}; +use unicode_segmentation::UnicodeSegmentation; +pub(crate) use widget::{Decorations, Snapshot}; + +#[derive(Debug)] +struct Part { + diagnostic: usize, + bytes: Range, +} + +/// Source-only index, reusable across pointer movement, scroll and reflow. +/// A caller must separately establish publication/path/snapshot authority. +#[derive(Debug)] +pub(crate) struct Index { + source: Arc, + lines: Vec>, + parts: BTreeMap>, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Band { + pub diagnostic: usize, + pub bounds: Rectangle, + pub underline: Rectangle, +} + +impl Index { + pub fn new(source: Arc, path: &str, diagnostics: &[SourceDiagnostic]) -> Self { + // This is the same paragraph splitter used by the actual editor engine, + // including standalone CR and LFCR, not the diagnostic's LF-only label. + let mut lines: Vec<_> = cosmic_text::LineIter::new(&source) + .map(|(bytes, _)| bytes) + .collect(); + if lines.last().is_none_or(|line| line.end < source.len()) { + lines.push(source.len()..source.len()); + } + let mut parts: BTreeMap> = BTreeMap::new(); + for (ordinal, diagnostic) in diagnostics.iter().enumerate() { + let span = diagnostic.diagnostic.span; + if diagnostic.path != path + || span.start > span.end + || span.end > source.len() + || !source.is_char_boundary(span.start) + || !source.is_char_boundary(span.end) + { + continue; + } + let first = lines.partition_point(|line| line.start <= span.start) - 1; + let last_byte = if span.start == span.end { + span.end + } else { + span.end - 1 + }; + let last = lines.partition_point(|line| line.start <= last_byte) - 1; + for (line_index, line) in lines.iter().enumerate().take(last + 1).skip(first) { + let start = span.start.max(line.start).min(line.end) - line.start; + let end = span.end.min(line.end) - line.start; + parts.entry(line_index).or_default().push(Part { + diagnostic: ordinal, + bytes: start..end, + }); + } + } + Self { + source, + lines, + parts, + } + } + + /// Only actual visible runs are projected. The engine's iterator already + /// applies scroll; the caller supplies the actual padded origin and clip. + pub fn bands(&self, buffer: &cosmic_text::Buffer, origin: Point, clip: Rectangle) -> Vec { + let mut bands = Vec::new(); + if self.parts.is_empty() || clip.width <= 0. || clip.height <= 0. { + return bands; + } + let translation = Vector::new(origin.x, origin.y); + let mut checked_line = None; + let mut source_matches = false; + for run in buffer.layout_runs() { + let Some(parts) = self.parts.get(&run.line_i) else { + continue; + }; + let underline_y = origin.y + run.line_top + run.line_height - 2.; + if underline_y >= clip.y + clip.height || underline_y + 1.5 <= clip.y { + continue; + } + // Several visible wraps can share a very large paragraph. Validate + // its exact bytes once in this projection, never in a stale cache. + if checked_line != Some(run.line_i) { + checked_line = Some(run.line_i); + source_matches = self + .lines + .get(run.line_i) + .and_then(|line| self.source.get(line.clone())) + == Some(run.text); + } + if !source_matches { + // A mismatched editor must never remap a diagnostic by text search. + continue; + } + let (first_byte, last_byte) = run + .glyphs + .iter() + .fold((usize::MAX, 0), |(first, last), glyph| { + (first.min(glyph.start), last.max(glyph.end)) + }); + for part in parts { + // Bidirectional glyphs need not be ordered by source byte. Their + // envelope safely rejects disjoint ranges before a glyph scan. + // Empty ranges retain the separate logical gap/EOF rules. + if !part.bytes.is_empty() + && (part.bytes.end <= first_byte || part.bytes.start >= last_byte) + { + continue; + } + let mut intervals: Vec<(f32, f32)> = if part.bytes.is_empty() { + anchor(buffer, &run, part.bytes.start) + .into_iter() + .map(|x| (x, x + 4.)) + .collect() + } else { + run.glyphs + .iter() + .filter(|glyph| { + glyph.start < part.bytes.end && part.bytes.start < glyph.end + }) + .map(|glyph| { + let left = glyph.x.min(glyph.x + glyph.w); + let right = glyph.x.max(glyph.x + glyph.w); + (left, right.max(left + 1.)) + }) + .collect() + }; + intervals.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut merged: Vec<(f32, f32)> = Vec::new(); + for (left, right) in intervals { + if let Some(previous) = merged.last_mut() + && left <= previous.1 + 0.01 + { + previous.1 = previous.1.max(right); + } else { + merged.push((left, right)); + } + } + for (mut left, mut right) in merged { + if part.bytes.is_empty() { + // An insertion at the right edge remains visible while + // still containing its true boundary, without borrowing a token. + let max_x = clip.x + clip.width - origin.x; + if left <= max_x && right > max_x { + right = max_x; + left = (right - 4.).max(0.); + } + } + let bounds = Rectangle::new( + Point::new(left, run.line_top), + Size::new(right - left, run.line_height), + ) + translation; + let underline = Rectangle { + y: bounds.y + bounds.height - 2., + height: 1.5, + ..bounds + }; + if let (Some(bounds), Some(underline)) = + (bounds.intersection(&clip), underline.intersection(&clip)) + && bounds.width > 0. + && bounds.height > 0. + && underline.width > 0. + && underline.height > 0. + { + bands.push(Band { + diagnostic: part.diagnostic, + bounds, + underline, + }); + } + } + } + } + bands + } +} + +/// Prefer the following cluster at a wrap boundary. EOF uses the preceding +/// cluster's trailing edge; an empty paragraph has a real origin insertion. +fn anchor( + buffer: &cosmic_text::Buffer, + run: &cosmic_text::LayoutRun<'_>, + byte: usize, +) -> Option { + if run.glyphs.is_empty() { + return (byte == 0 && run.text.is_empty()).then_some(0.); + } + if let Some(glyph) = run + .glyphs + .iter() + .find(|glyph| glyph.start <= byte && byte < glyph.end) + { + // Match cosmic-text's cursor heuristic inside a shaped cluster: a + // proportional grapheme advance, including ligatures. This describes + // a caret affordance, not an exact font-provided ligature caret or ink. + let cluster = run.text.get(glyph.start..glyph.end)?; + let mut before = 0; + let mut total = 0; + for (offset, _) in cluster.grapheme_indices(true) { + before += usize::from(glyph.start + offset < byte); + total += 1; + } + let advance = glyph.w * before as f32 / total.max(1) as f32; + return Some(if glyph.level.is_rtl() { + glyph.x + glyph.w - advance + } else { + glyph.x + advance + }); + } + // Visual order need not be logical order, even between wrapped runs. First + // find this run's best logical predecessor, not its largest end overall. + let previous = run + .glyphs + .iter() + .filter(|glyph| glyph.end <= byte) + .max_by_key(|glyph| (glyph.end, glyph.start))?; + // Most unrelated runs fail here without scanning the paragraph's layout. + // Only a genuinely omitted separator or EOF may use a predecessor edge. + if !run + .text + .get(previous.end..byte)? + .chars() + .all(char::is_whitespace) + || (byte != run.text.len() && !run.text.get(byte..)?.chars().next()?.is_whitespace()) + { + return None; + } + let layout = buffer.lines.get(run.line_i)?.layout_opt()?; + let key = (previous.end, previous.start); + let mut current_found = false; + for line in layout { + let current = std::ptr::eq(line.glyphs.as_slice(), run.glyphs); + current_found |= current; + for glyph in &line.glyphs { + // A painted source position, including an exact next-run start, + // belongs to that containing run, never to a gap fallback here. + if glyph.start <= byte && byte < glyph.end { + return None; + } + let candidate = (glyph.end, glyph.start); + if glyph.end <= byte && candidate > key { + return None; + } + // A cluster split across visual runs has no single run-local edge. + // Suppress unavailable geometry instead of moving it to a neighbor. + if !current && candidate == key { + return None; + } + } + } + if !current_found { + return None; + } + cluster_trailing_edge(run.glyphs, previous) +} + +fn cluster_trailing_edge( + glyphs: &[cosmic_text::LayoutGlyph], + cluster: &cosmic_text::LayoutGlyph, +) -> Option { + // Multiple glyphs can share a source cluster. Their iterator order follows + // paragraph layout, so its last entry need not be the logical trailing edge. + let rtl = cluster.level.is_rtl(); + let mut edge = None; + for glyph in glyphs + .iter() + .filter(|glyph| glyph.start == cluster.start && glyph.end == cluster.end) + { + if glyph.level.is_rtl() != rtl { + return None; + } + let next = if rtl { + glyph.x.min(glyph.x + glyph.w) + } else { + glyph.x.max(glyph.x + glyph.w) + }; + edge = Some(edge.map_or(next, |value: f32| { + if rtl { + value.min(next) + } else { + value.max(next) + } + })); + } + edge +} + +#[cfg(test)] +mod layout_tests; +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod anchor_tests; +#[cfg(test)] +mod performance_tests; diff --git a/crates/oa-desktop/src/source_annotations/anchor_tests.rs b/crates/oa-desktop/src/source_annotations/anchor_tests.rs new file mode 100644 index 00000000..5063ff7e --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/anchor_tests.rs @@ -0,0 +1,260 @@ +//! Manual actual-font witnesses; missing supported glyphs fail fixture admission. +use super::*; +use iced::advanced::{ + graphics::text::Editor, + text::{ + Editor as _, Highlighter as _, LineHeight, Wrapping, + highlighter::{Format, PlainText}, + }, +}; +use iced::{Font, Pixels}; + +fn shaped(source: &str, width: f32) -> Editor { + let mut editor = Editor::with_text(source); + let mut highlighter = PlainText::new(&()); + editor.update( + Size::new(width, 1200.), + Font::MONOSPACE, + Pixels(16.), + LineHeight::Absolute(Pixels(24.)), + Wrapping::WordOrGlyph, + &mut highlighter, + ); + editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default()); + editor +} + +fn trailing(cluster: &[&cosmic_text::LayoutGlyph]) -> f32 { + let rtl = cluster[0].level.is_rtl(); + assert!(cluster.iter().all(|g| g.level.is_rtl() == rtl)); + if rtl { + cluster + .iter() + .map(|g| g.x.min(g.x + g.w)) + .fold(f32::INFINITY, f32::min) + } else { + cluster + .iter() + .map(|g| g.x.max(g.x + g.w)) + .fold(f32::NEG_INFINITY, f32::max) + } +} + +#[test] +#[ignore = "manual supported-font cluster and bidi witnesses"] +fn final_multi_glyph_cluster_anchor_uses_logical_trailing_advance_edge() { + // Marks without precomposed forms exercise real multi-glyph shaping. Test + // both paragraph directions and both final-cluster directions. No font is + // assumed to support a given specimen; glyph_id=0 cannot admit a witness. + let sources = [ + "abc q\u{301}\u{323}", + "אבג q\u{301}\u{323}", + "abc א\u{5b0}\u{5bc}", + "אבג א\u{5b0}\u{5bc}", + "abc ب\u{64e}\u{651}", + "אבג ب\u{64e}\u{651}", + "abc x\u{20dd}", + "אבג x\u{20dd}", + ]; + let mut admitted = 0; + let mut opposite_direction = 0; + for source in sources { + let editor = shaped(source, 700.); + let runs: Vec<_> = editor.buffer().layout_runs().collect(); + assert_eq!(runs.len(), 1, "wide specimen unexpectedly wrapped"); + let run = &runs[0]; + let Some(start) = run + .glyphs + .iter() + .filter(|g| g.end == source.len()) + .map(|g| g.start) + .max() + else { + continue; + }; + let cluster: Vec<_> = run + .glyphs + .iter() + .filter(|g| g.start == start && g.end == source.len()) + .collect(); + if cluster.len() < 2 || cluster.iter().any(|g| g.glyph_id == 0) { + continue; + } + admitted += 1; + opposite_direction += usize::from(cluster[0].level.is_rtl() != run.rtl); + let expected = trailing(&cluster); + let actual = anchor(editor.buffer(), run, source.len()); + eprintln!( + "EOF witness {source:?}: paragraph_rtl={} cluster={cluster:?} expected={expected} actual={actual:?}", + run.rtl + ); + assert!( + actual.is_some_and(|x| (x - expected).abs() < 0.01), + "logical EOF must follow the whole cluster, including shared-range glyphs; source={source:?}" + ); + } + assert!( + admitted > 0, + "fixture admission: current fonts produced no supported multi-glyph final cluster" + ); + assert!( + opposite_direction > 0, + "fixture admission: no supported final cluster opposed its paragraph direction" + ); +} + +#[test] +#[ignore = "manual supported-font cluster and bidi witnesses"] +fn mixed_direction_omitted_separator_has_one_logical_predecessor_anchor() { + let sources = [ + "one אבג דהו two זחט three יכל four מנס five עפצ six קרש seven", + "אבג one two דהו three four זחט five six יכל seven eight מנס nine", + ]; + let mut admitted = 0; + let mut rtl_predecessors = 0; + let mut failures = Vec::new(); + let mut old_mismatches = 0; + let mut old_inside_mismatches = 0; + for source in sources { + for width in [72., 96., 120., 144., 180.] { + let editor = shaped(source, width); + let runs: Vec<_> = editor.buffer().layout_runs().collect(); + assert!(runs.len() > 1); + assert_eq!( + runs.len(), + editor.buffer().lines[0].layout_opt().unwrap().len(), + "negative control requires the complete unscrolled paragraph" + ); + assert!( + runs.iter() + .any(|r| r.glyphs.iter().any(|g| g.level.is_rtl())) + ); + assert!( + runs.iter() + .any(|r| r.glyphs.iter().any(|g| !g.level.is_rtl())) + ); + for (byte, character) in source.char_indices().filter(|(_, c)| *c == ' ') { + // Require a genuinely omitted separator, not a rendered space. + if runs + .iter() + .any(|r| r.glyphs.iter().any(|g| g.start <= byte && byte < g.end)) + { + continue; + } + let Some(end) = runs + .iter() + .flat_map(|r| r.glyphs) + .filter(|g| g.end <= byte) + .map(|g| g.end) + .max() + else { + continue; + }; + let Some(next) = runs + .iter() + .flat_map(|r| r.glyphs) + .filter(|g| g.start >= byte + character.len_utf8()) + .map(|g| g.start) + .min() + else { + continue; + }; + if !source[end..next].chars().all(char::is_whitespace) { + continue; + } + let previous: Vec<_> = runs + .iter() + .enumerate() + .filter(|(_, r)| r.glyphs.iter().any(|g| g.end == end)) + .collect(); + // A cluster broken across runs is a separate boundary problem; + // it is not admitted as this omitted-separator witness. + if previous.len() != 1 { + continue; + } + let (owner, run) = previous[0]; + let start = run + .glyphs + .iter() + .filter(|g| g.end == end) + .map(|g| g.start) + .max() + .unwrap(); + let cluster: Vec<_> = run + .glyphs + .iter() + .filter(|g| g.start == start && g.end == end) + .collect(); + if cluster.iter().any(|g| g.glyph_id == 0) { + continue; + } + admitted += 1; + rtl_predecessors += usize::from(cluster[0].level.is_rtl()); + let expected = trailing(&cluster); + let actual: Vec<_> = runs + .iter() + .enumerate() + .filter_map(|(i, r)| anchor(editor.buffer(), r, byte).map(|x| (i, x))) + .collect(); + let matches_expected = |positions: &[(usize, f32)]| { + positions.len() == 1 + && positions[0].0 == owner + && (positions[0].1 - expected).abs() < 0.01 + }; + // Exact rejected fallback, for already-proven omitted bytes: + // per-run maximum end and the next visual run's minimum start. + let old: Vec<_> = runs + .iter() + .enumerate() + .filter_map(|(i, r)| { + let last = r.glyphs.iter().max_by_key(|g| g.end)?; + let next_start = runs + .get(i + 1) + .and_then(|next| next.glyphs.iter().map(|g| g.start).min()); + (byte >= last.end + && (next_start.is_some_and(|start| byte < start) + || (next_start.is_none() && byte == source.len()))) + .then_some(( + i, + if last.level.is_rtl() { + last.x + } else { + last.x + last.w + }, + )) + }) + .collect(); + let inside_clip = expected >= 0. && expected <= width; + if !matches_expected(&old) { + old_mismatches += 1; + old_inside_mismatches += usize::from(inside_clip); + } + eprintln!( + "gap witness width={width} source={source:?} byte={byte} owner={owner} expected={expected} inside_horizontal_clip={inside_clip} actual={actual:?} rejected_old={old:?} cluster={cluster:?}" + ); + if !matches_expected(&actual) { + failures.push(format!("width={width} source={source:?} byte={byte} expected=({owner},{expected}) inside={inside_clip} actual={actual:?}")); + } + } + } + } + assert!( + admitted > 0, + "fixture admission: no omitted mixed-direction separator found" + ); + assert!( + rtl_predecessors > 0, + "fixture admission: no omitted separator after a supported RTL cluster" + ); + eprintln!( + "gap summary admitted={admitted} rejected_old_mismatches={old_mismatches} rejected_old_inside_horizontal_clip={old_inside_mismatches}" + ); + assert!( + old_mismatches > 0, + "actual-layout negative control must discriminate the old fallback" + ); + assert!( + failures.is_empty(), + "mixed-bidi logical anchor failures: {failures:#?}" + ); +} diff --git a/crates/oa-desktop/src/source_annotations/layout_tests.rs b/crates/oa-desktop/src/source_annotations/layout_tests.rs new file mode 100644 index 00000000..1e9dbc41 --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/layout_tests.rs @@ -0,0 +1,321 @@ +//! Real shaped-layout projection checks. No window/GPU or hover authority claim. +use super::*; +use iced::advanced::{ + graphics::text::Editor, + text::{ + Editor as _, Highlighter as _, LineHeight, Wrapping, + highlighter::{Format, PlainText}, + }, +}; +use iced::{Font, Pixels}; +use oa_core::{ + language::{Diagnostic, Span}, + validation::Severity, +}; + +const ORIGIN: Point = Point::new(37., 53.); +fn diagnostic(start: usize, end: usize) -> SourceDiagnostic { + SourceDiagnostic { + path: "a".into(), + diagnostic: Diagnostic { + severity: Severity::Error, + code: "layout".into(), + message: "fixture".into(), + object_id: None, + field: None, + line: 999, + column: 999, + span: Span { start, end }, + }, + related: Vec::new(), + } +} +fn editor(source: &str, width: f32, height: f32) -> Editor { + let mut editor = Editor::with_text(source); + let mut highlighter = PlainText::new(&()); + editor.update( + Size::new(width, height), + Font::MONOSPACE, + Pixels(16.), + LineHeight::Absolute(Pixels(24.)), + Wrapping::WordOrGlyph, + &mut highlighter, + ); + editor.highlight(Font::MONOSPACE, &mut highlighter, |_| Format::default()); + editor +} +fn index(source: &str, ranges: &[Range]) -> Index { + Index::new( + Arc::from(source), + "a", + &ranges + .iter() + .map(|r| diagnostic(r.start, r.end)) + .collect::>(), + ) +} +fn clip(width: f32, height: f32) -> Rectangle { + Rectangle::new(ORIGIN, Size::new(width, height)) +} +fn covered(bands: &[Band], point: Point, ordinal: usize) -> bool { + bands + .iter() + .any(|b| b.diagnostic == ordinal && b.bounds.contains(point)) +} +fn center(run: &cosmic_text::LayoutRun<'_>, glyph: &cosmic_text::LayoutGlyph) -> Point { + Point::new( + ORIGIN.x + glyph.x + glyph.w / 2., + ORIGIN.y + run.line_top + run.line_height / 2., + ) +} + +#[test] +fn repeated_alias_marks_only_second_actual_glyph_sequence_and_blank_space_misses() { + let source = "alias alias"; + let editor = editor(source, 500., 120.); + let bands = index(source, std::slice::from_ref(&(6..11))).bands( + editor.buffer(), + ORIGIN, + clip(500., 120.), + ); + let run = editor.buffer().layout_runs().next().unwrap(); + let mut selected = 0; + let mut rejected = 0; + for glyph in run.glyphs { + let at = center(&run, glyph); + if glyph.start >= 6 { + assert!(covered(&bands, at, 0)); + selected += 1; + } + if glyph.end <= 5 { + assert!(!covered(&bands, at, 0)); + rejected += 1; + } + } + assert!(selected > 0 && rejected > 0); + assert!(!covered( + &bands, + Point::new(ORIGIN.x + 450., ORIGIN.y + 12.), + 0 + )); + assert!(!covered( + &bands, + Point::new(ORIGIN.x + 12., ORIGIN.y + 100.), + 0 + )); + // Wrong-origin negative control: expected source glyphs are not at raw local positions. + assert!(!covered(&bands, Point::new(0., 0.), 0)); +} + +#[test] +fn wrapped_scrolled_multiline_range_uses_actual_visible_runs_and_effective_clip() { + let source = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu\nsecond source line has more words\nthird source line\n"; + let mut editor = editor(source, 150., 110.); + assert!( + editor + .buffer() + .layout_runs() + .filter(|r| r.line_i == 0) + .count() + >= 2 + ); + editor.perform(iced::widget::text_editor::Action::Scroll { lines: 2 }); + let scroll = editor.buffer().scroll(); + assert!(scroll.line > 0 || scroll.vertical > 0.); + let effective = Rectangle::new( + Point::new(ORIGIN.x + 9., ORIGIN.y + 5.), + Size::new(126., 91.), + ); + let bands = index(source, std::slice::from_ref(&(0..source.len()))).bands( + editor.buffer(), + ORIGIN, + effective, + ); + assert!(!bands.is_empty()); + for b in &bands { + assert_eq!(b.bounds.intersection(&effective), Some(b.bounds)); + assert_eq!(b.underline.intersection(&effective), Some(b.underline)); + } + let mut checked = 0; + for run in editor.buffer().layout_runs() { + // Only rows whose actual underline is inside the clip are hoverable by policy. + let underline_y = ORIGIN.y + run.line_top + run.line_height - 1.; + if underline_y < effective.y || underline_y >= effective.y + effective.height { + continue; + } + for glyph in run.glyphs { + let at = center(&run, glyph); + if effective.contains(at) { + assert!(covered(&bands, at, 0), "visible glyph {glyph:?}"); + checked += 1; + } + } + } + assert!(checked > 0); + assert_eq!(editor.buffer().scroll(), scroll); + assert!( + index(source, std::slice::from_ref(&(0..source.len()))) + .bands( + editor.buffer(), + ORIGIN, + Rectangle::new(Point::new(-500., -500.), Size::new(10., 10.)) + ) + .is_empty() + ); +} + +#[test] +fn combining_cjk_and_bidi_ranges_cover_actual_clusters_without_neighbor_aliases() { + let source = "left e\u{301} 救援 abc אבג xyz right"; + let editor = editor(source, 700., 120.); + let combining = source.find("e\u{301}").unwrap(); + let cjk = source.find('救').unwrap(); + let rtl = source.find('א').unwrap(); + let ranges = [ + combining + 1..combining + 3, + cjk..cjk + "救援".len(), + rtl..rtl + "אבג".len(), + ]; + let bands = index(source, &ranges).bands(editor.buffer(), ORIGIN, clip(700., 120.)); + let run = editor.buffer().layout_runs().next().unwrap(); + assert!( + run.glyphs.iter().any(|g| g.level.is_rtl()), + "fixture must shape RTL" + ); + assert!( + run.glyphs + .iter() + .any(|g| g.start <= combining && g.end >= combining + 3), + "fixture must shape combining cluster" + ); + for (ordinal, range) in ranges.iter().enumerate() { + let targets: Vec<_> = run + .glyphs + .iter() + .filter(|g| g.start < range.end && range.start < g.end) + .collect(); + assert!(!targets.is_empty()); + for g in targets { + assert!(covered(&bands, center(&run, g), ordinal)); + } + } + for g in run.glyphs.iter().filter(|g| { + g.end <= 4 + || g.start >= source.find("right").unwrap() + || (g.start >= source.find("abc").unwrap() && g.end <= source.find("abc").unwrap() + 3) + || (g.start >= source.find("xyz").unwrap() && g.end <= source.find("xyz").unwrap() + 3) + }) { + assert!(!(0..ranges.len()).any(|n| covered(&bands, center(&run, g), n))); + } +} + +#[test] +fn omitted_wrap_space_has_one_preceding_run_anchor_and_none_when_that_run_is_clipped_out() { + let source = "one two three four five six seven eight nine ten"; + let mut found = None; + for width in [60., 72., 84., 96., 108., 120., 132., 144.] { + let e = editor(source, width, 600.); + let gap = { + let runs: Vec<_> = e.buffer().layout_runs().collect(); + runs.windows(2).find_map(|pair| { + if pair[0].line_i != pair[1].line_i { + return None; + } + let before = pair[0].glyphs.iter().max_by_key(|g| g.end)?; + let after = pair[1].glyphs.iter().map(|g| g.start).min()?; + (before.end < after && source[before.end..after].chars().all(char::is_whitespace)) + .then_some(( + before.end, + before.x + before.w, + pair[0].line_top, + pair[0].line_height, + pair[1].line_top, + )) + }) + }; + if let Some(gap) = gap { + found = Some((e, width, gap)); + break; + } + } + let (editor, width, (byte, edge, top, height, next_top)) = + found.expect("actual wrapping must omit a separator; do not silently skip fixture"); + let idx = index(source, std::slice::from_ref(&(byte..byte))); + let bands = idx.bands(editor.buffer(), ORIGIN, clip(width, 600.)); + assert_eq!( + bands.len(), + 1, + "exactly the preceding actual run owns the gap" + ); + assert!((bands[0].bounds.y - (ORIGIN.y + top)).abs() < 0.01); + assert!( + bands[0].bounds.x <= ORIGIN.x + edge + && bands[0].bounds.x + bands[0].bounds.width >= ORIGIN.x + edge + ); + assert!((bands[0].bounds.height - height).abs() < 0.01); + let later_only = Rectangle::new( + Point::new(ORIGIN.x, ORIGIN.y + next_top), + Size::new(width, 100.), + ); + assert!( + idx.bands(editor.buffer(), ORIGIN, later_only).is_empty(), + "must not relocate hidden prior-run anchor into next visible run" + ); +} + +#[test] +fn empty_eof_and_separator_ranges_follow_real_paragraph_ownership() { + for source in ["", "a", "a\n"] { + let editor = editor(source, 200., 200.); + let byte = source.len(); + let bands = index(source, std::slice::from_ref(&(byte..byte))).bands( + editor.buffer(), + ORIGIN, + clip(200., 200.), + ); + assert_eq!(bands.len(), 1, "EOF {source:?}"); + let run = editor.buffer().layout_runs().last().unwrap(); + assert!((bands[0].bounds.y - (ORIGIN.y + run.line_top)).abs() < 0.01); + let edge = run.glyphs.last().map_or(0., |g| g.x + g.w); + assert!( + bands[0].bounds.x <= ORIGIN.x + edge + && bands[0].bounds.x + bands[0].bounds.width >= ORIGIN.x + edge + ); + } + let source = "a\r\n\r\n雨\nz"; + let editor = editor(source, 200., 200.); + let bands = + index(source, &[0..source.len(), 1..3]).bands(editor.buffer(), ORIGIN, clip(200., 200.)); + let runs: Vec<_> = editor.buffer().layout_runs().collect(); + assert_eq!(runs.len(), 4); + for run in &runs { + let x = run.glyphs.first().map_or(2., |g| g.x + g.w / 2.); + assert!(covered( + &bands, + Point::new(ORIGIN.x + x, ORIGIN.y + run.line_top + run.line_height / 2.), + 0 + )); + } + let separator: Vec<_> = bands.iter().filter(|b| b.diagnostic == 1).collect(); + assert_eq!(separator.len(), 1); + assert_eq!(separator[0].bounds.y, ORIGIN.y + runs[0].line_top); +} + +#[test] +fn mismatched_affected_source_and_bottom_line_without_visible_underline_have_no_bands() { + let actual = editor("other", 300., 100.); + assert!( + index("alias", std::slice::from_ref(&(0..5))) + .bands(actual.buffer(), ORIGIN, clip(300., 100.)) + .is_empty() + ); + let actual = editor("alias", 300., 100.); + let run = actual.buffer().layout_runs().next().unwrap(); + let top_only = Rectangle::new(ORIGIN, Size::new(300., run.line_height / 2.)); + assert!( + index("alias", std::slice::from_ref(&(0..5))) + .bands(actual.buffer(), ORIGIN, top_only) + .is_empty(), + "partially visible text with clipped underline is not hoverable by declared policy" + ); +} diff --git a/crates/oa-desktop/src/source_annotations/performance_tests.rs b/crates/oa-desktop/src/source_annotations/performance_tests.rs new file mode 100644 index 00000000..6fcd990a --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/performance_tests.rs @@ -0,0 +1,216 @@ +//! Manual actual-layout timing distributions; no fixed machine threshold. +use super::*; +use iced::advanced::{ + graphics::text::Editor, + text::{ + Editor as _, Highlighter as _, LineHeight, Wrapping, + highlighter::{Format, PlainText}, + }, +}; +use iced::{Font, Pixels}; +use oa_core::{ + language::{Diagnostic, Span}, + validation::Severity, +}; +use std::{hint::black_box, time::Instant}; + +fn diagnostic(start: usize, end: usize) -> SourceDiagnostic { + SourceDiagnostic { + path: "a".into(), + diagnostic: Diagnostic { + severity: Severity::Warning, + code: "perf".into(), + message: "prepared geometry specimen".into(), + object_id: None, + field: None, + line: 0, + column: 0, + span: Span { start, end }, + }, + related: Vec::new(), + } +} +fn shaped(source: &str) -> Editor { + let mut e = Editor::with_text(source); + settle_layout(&mut e); + e +} + +// Stock TextEditor calls Editor::update during the next layout after input. +// Scroll initially changes pixel offset only; update normalizes logical scroll +// and shapes newly visible paragraphs before draw/highlight consumes them. +fn settle_layout(e: &mut Editor) { + let mut h = PlainText::new(&()); + e.update( + Size::new(640., 240.), + Font::MONOSPACE, + Pixels(16.), + LineHeight::Absolute(Pixels(24.)), + Wrapping::WordOrGlyph, + &mut h, + ); + e.highlight(Font::MONOSPACE, &mut h, |_| Format::default()); +} +fn distribution(label: &str, batch: usize, mut operation: impl FnMut() -> T) { + drop(black_box(operation())); // one excluded warmup + let mut samples = Vec::new(); + for _ in 0..5 { + let mut elapsed = 0; + for _ in 0..batch { + let start = Instant::now(); + let value = operation(); + black_box(&value); + elapsed += start.elapsed().as_nanos(); + drop(value); // allocation/destruction separated: operation includes allocation, excludes drop + } + samples.push(elapsed / batch as u128); + } + let mut sorted = samples.clone(); + sorted.sort_unstable(); + println!( + "TIMING {label} batch={batch} warmup=1 measured=5 ns_per_call={samples:?} min={} median={} max={} drop_excluded=true", + sorted[0], sorted[2], sorted[4] + ); +} +fn run_view(label: &str, source: Arc, editor: &Editor, dense: &[SourceDiagnostic]) { + let buffer = editor.buffer(); + let runs: Vec<_> = buffer.layout_runs().collect(); + assert!(!runs.is_empty()); + let visible_glyphs: usize = runs.iter().map(|r| r.glyphs.len()).sum(); + let first = runs + .iter() + .find_map(|r| { + r.glyphs + .iter() + .find(|g| g.w > 0.) + .map(|g| (r.line_i, g.start, g.end)) + }) + .unwrap(); + let line_starts: Vec<_> = cosmic_text::LineIter::new(&source) + .map(|(r, _)| r.start) + .collect(); + let sparse = [diagnostic( + line_starts[first.0] + first.1, + line_starts[first.0] + first.2, + )]; + let sparse_index = Index::new(source.clone(), "a", &sparse); + let dense_index = Index::new(source.clone(), "a", dense); + let empty_index = Index::new(source.clone(), "a", &[]); + let origin = Point::new(17., 29.); + let clip = Rectangle::new(origin, Size::new(640., 240.)); + let sparse_bands = sparse_index.bands(buffer, origin, clip); + assert!(!sparse_bands.is_empty(), "positive visible glyph fixture"); + assert!(empty_index.bands(buffer, origin, clip).is_empty()); + let rejected = Rectangle::new(Point::new(-1000., -1000.), Size::new(1., 1.)); + assert!( + dense_index.bands(buffer, origin, rejected).is_empty(), + "offscreen-output negative control" + ); + let dense_count = dense_index.bands(buffer, origin, clip).len(); + println!( + "CASE {label} bytes={} paragraphs={} diagnostics={} visible_runs={} visible_glyphs={visible_glyphs} dense_bands={dense_count} scroll={:?}", + source.len(), + buffer.lines.len(), + dense.len(), + runs.len(), + buffer.scroll() + ); + distribution(&format!("{label}/index-build-dense"), 1, || { + Index::new(black_box(source.clone()), "a", black_box(dense)) + }); + distribution(&format!("{label}/layout-runs-only"), 8, || { + black_box(buffer) + .layout_runs() + .map(|r| r.glyphs.len()) + .sum::() + }); + distribution(&format!("{label}/bands-no-diagnostics"), 8, || { + empty_index.bands(black_box(buffer), origin, clip) + }); + distribution(&format!("{label}/bands-one-visible"), 8, || { + sparse_index.bands(black_box(buffer), origin, clip) + }); + distribution(&format!("{label}/bands-dense"), 8, || { + dense_index.bands(black_box(buffer), origin, clip) + }); + distribution(&format!("{label}/bands-dense-rejected-clip"), 8, || { + dense_index.bands(black_box(buffer), origin, rejected) + }); +} + +#[test] +#[ignore = "manual diagnostic projection distributions; no fixed machine threshold"] +fn annotation_projection_cost_distributions() { + // Ordinary file: many logical paragraphs; deep scroll can skip by paragraph. + let line = "alpha beta gamma delta epsilon\n"; + let ordinary: Arc = Arc::from(line.repeat(4000)); + let ordinary_diagnostics: Vec<_> = (0..4000) + .map(|i| diagnostic(i * line.len(), i * line.len() + 5)) + .collect(); + let mut ordinary_editor = shaped(&ordinary); + run_view( + "ordinary/top", + ordinary.clone(), + &ordinary_editor, + &ordinary_diagnostics, + ); + ordinary_editor.perform(iced::widget::text_editor::Action::Scroll { lines: 2000 }); + settle_layout(&mut ordinary_editor); + assert!( + ordinary_editor.buffer().scroll().line > 1000, + "must really reach a deep logical paragraph" + ); + assert!( + ordinary_editor + .buffer() + .layout_runs() + .next() + .unwrap() + .line_i + > 1000, + "newly visible deep paragraph must actually be shaped" + ); + run_view( + "ordinary/deep", + ordinary, + &ordinary_editor, + &ordinary_diagnostics, + ); + + // Single paragraph: diagnostics near beginning remain indexed on the same + // logical line even when the visible runs are far below all of them. + let token = "alpha beta gamma delta "; + let single: Arc = Arc::from(token.repeat(6000)); + let dense: Vec<_> = (0..1500) + .map(|i| diagnostic(i * token.len(), i * token.len() + 5)) + .collect(); + let mut single_editor = shaped(&single); + let wrap_count = single_editor.buffer().lines[0].layout_opt().unwrap().len(); + assert!(wrap_count > 1000, "fixture must shape thousands of wraps"); + run_view("single/top", single.clone(), &single_editor, &dense); + single_editor.perform(iced::widget::text_editor::Action::Scroll { + lines: (wrap_count / 2) as i32, + }); + settle_layout(&mut single_editor); + let buffer = single_editor.buffer(); + assert_eq!(buffer.scroll().line, 0); + assert!( + buffer.scroll().vertical > 1000., + "must really scroll within same paragraph" + ); + let first_visible = buffer.layout_runs().next().unwrap(); + assert!( + first_visible.glyphs.iter().map(|g| g.start).min().unwrap() + > dense.last().unwrap().diagnostic.span.end, + "dense diagnostics must all be above the deep viewport" + ); + run_view( + "single/deep-offscreen-diagnostics", + single, + &single_editor, + &dense, + ); + println!( + "DONE manual cost distributions; compare controls and top/deep ratios, not a platform-independent latency PASS" + ); +} diff --git a/crates/oa-desktop/src/source_annotations/tests.rs b/crates/oa-desktop/src/source_annotations/tests.rs new file mode 100644 index 00000000..d8624d6e --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/tests.rs @@ -0,0 +1,83 @@ +use super::*; +use oa_core::{ + language::{Diagnostic, Span}, + validation::Severity, +}; + +fn diagnostic(path: &str, start: usize, end: usize) -> SourceDiagnostic { + SourceDiagnostic { + path: path.into(), + diagnostic: Diagnostic { + severity: Severity::Error, + code: "test".into(), + message: "synthetic admission fixture".into(), + object_id: None, + field: None, + line: 999, + column: 999, + span: Span { start, end }, + }, + related: Vec::new(), + } +} + +#[test] +fn exact_path_and_utf8_range_admission_keep_valid_neighbor() { + let index = Index::new( + Arc::from("雨 x"), + "a.arcadia", + &[ + diagnostic("b.arcadia", 0, 3), + diagnostic("a.arcadia", 1, 3), + diagnostic("a.arcadia", 4, 2), + diagnostic("a.arcadia", 0, 99), + diagnostic("a.arcadia", 4, 5), + ], + ); + assert_eq!(index.parts.len(), 1); + let parts = &index.parts[&0]; + assert_eq!(parts.len(), 1); + assert_eq!((parts[0].diagnostic, parts[0].bytes.clone()), (4, 4..5)); +} + +#[test] +fn mixed_endings_use_actual_paragraphs_and_preserve_eof() { + let index = Index::new( + Arc::from("a\r\n雨\rb\n\rc\n"), + "a", + &[ + diagnostic("a", 3, 6), + diagnostic("a", 7, 8), + diagnostic("a", 12, 12), + ], + ); + assert_eq!(index.lines, vec![0..1, 3..6, 7..8, 10..11, 12..12]); + assert_eq!(index.parts[&1][0].bytes, 0..3); + assert_eq!(index.parts[&2][0].bytes, 0..1); + assert_eq!(index.parts[&4][0].bytes, 0..0); +} + +#[test] +fn half_open_next_line_excluded_separator_only_and_empty_line_anchored() { + let index = Index::new( + Arc::from("ab\r\n\r\ncd"), + "a", + &[ + diagnostic("a", 0, 4), + diagnostic("a", 2, 4), + diagnostic("a", 4, 6), + ], + ); + assert_eq!(index.parts.len(), 2); + assert_eq!(index.parts[&0][0].bytes, 0..2); + assert_eq!(index.parts[&0][1].bytes, 2..2); + assert_eq!(index.parts[&1][0].bytes, 0..0); + assert!(!index.parts.contains_key(&2)); +} + +#[test] +fn empty_source_has_real_zero_width_marker() { + let index = Index::new(Arc::from(""), "a", &[diagnostic("a", 0, 0)]); + assert_eq!(index.lines, vec![0..0]); + assert_eq!(index.parts[&0][0].bytes, 0..0); +} diff --git a/crates/oa-desktop/src/source_annotations/widget.rs b/crates/oa-desktop/src/source_annotations/widget.rs new file mode 100644 index 00000000..29499c79 --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/widget.rs @@ -0,0 +1,425 @@ +//! Permanent passive decoration around the existing editor. Draw uses current +//! geometry; only the source index is cached, never pointer hitboxes or messages. +//! Current-frame tooltip paragraphs stay owned until deferred renderer preparation. +use super::Index; +use iced::advanced::text::Paragraph as _; +use iced::{ + Element, Event, Font, Length, Padding, Point, Rectangle, Size, Theme, Vector, + advanced::{ + Clipboard, Layout, Shell, Widget, graphics, layout, overlay, renderer, text, widget, + }, + alignment, mouse, + widget::text_editor, +}; +use oa_core::{Id, source_project::SourceDiagnostic, validation::Severity}; +use std::{cell::RefCell, sync::Arc}; + +pub(crate) struct Decorations<'a, Message, R: text::Renderer> { + child: Element<'a, Message, Theme, R>, + content: &'a text_editor::Content, + snapshot: Option>, + padding: Padding, + hover_enabled: bool, +} + +/// Supplied only after the App admits a current diagnostic publication for the +/// displayed draft. Absence keeps the editor mounted with no annotations. +pub(crate) struct Snapshot<'a> { + pub publication: Id, + pub source: Arc, + pub path: &'a str, + pub diagnostics: &'a [SourceDiagnostic], +} + +impl<'a, Message, R: text::Renderer> Decorations<'a, Message, R> { + pub fn new( + child: impl Into>, + content: &'a text_editor::Content, + snapshot: Option>, + padding: Padding, + hover_enabled: bool, + ) -> Self { + Self { + child: child.into(), + content, + snapshot, + padding, + hover_enabled, + } + } +} + +struct Cached { + publication: Id, + path: String, + source: Arc, + index: Index, +} +#[derive(Default)] +struct State { + index: RefCell>, + details: RefCell>, +} + +// WGPU queues weak paragraph references during draw and upgrades them later. +// This is render-resource ownership, not a cache authorizing future hover hits. +struct DetailsParagraphs { + _body: graphics::text::Paragraph, + _footer: graphics::text::Paragraph, +} + +impl Widget for Decorations<'_, Message, R> +where + R: text::Renderer< + Font = Font, + Editor = graphics::text::Editor, + Paragraph = graphics::text::Paragraph, + >, +{ + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State::default()) + } + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.child)] + } + fn diff(&self, tree: &mut widget::Tree) { + // A publication change only replaces the read-only index. The editor's + // existing Tree and hidden selection/IME/scroll state remain mounted. + tree.diff_children(std::slice::from_ref(&self.child)); + } + fn size(&self) -> Size { + self.child.as_widget().size() + } + fn size_hint(&self) -> Size { + self.child.as_widget().size_hint() + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &R, + limits: &layout::Limits, + ) -> layout::Node { + self.child + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &R, + operation: &mut dyn widget::Operation, + ) { + self.child + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if matches!( + event, + Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorLeft) + ) { + // Movement within a focused editor need not change stock status. + // Request painting, but do not capture or publish any input action. + shell.request_redraw(); + } + self.child.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut R, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.child.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + let state = tree.state.downcast_ref::(); + // Revoke the preceding frame's text even on every early/hidden path. + // Current source admission and current geometry alone decide new text. + let mut details = state.details.borrow_mut(); + *details = None; + let mut cache = state.index.borrow_mut(); + let Some(snapshot) = &self.snapshot else { + *cache = None; + return; + }; + let inner = layout.bounds().shrink(self.padding); + let Some(clip) = inner + .intersection(viewport) + .filter(|clip| clip.width > 0. && clip.height > 0.) + else { + return; + }; + if cache.as_ref().is_none_or(|cached| { + cached.publication != snapshot.publication + || cached.path != snapshot.path + || !Arc::ptr_eq(&cached.source, &snapshot.source) + }) { + *cache = Some(Cached { + publication: snapshot.publication, + path: snapshot.path.into(), + source: snapshot.source.clone(), + index: Index::new(snapshot.source.clone(), snapshot.path, snapshot.diagnostics), + }); + } + // Child draw has now applied the actual syntax highlighter. Release the + // Content borrow before further renderer work; no callback reentrancy. + let mut bands = self.content.with_editor(|editor| { + cache + .as_ref() + .unwrap() + .index + .bands(editor.buffer(), inner.position(), clip) + }); + bands.sort_by_key( + |band| match snapshot.diagnostics[band.diagnostic].diagnostic.severity { + Severity::Warning => 0, + Severity::Error => 1, + }, + ); + let palette = theme.palette(); + renderer.start_layer(clip); + for band in &bands { + let color = match snapshot.diagnostics[band.diagnostic].diagnostic.severity { + Severity::Warning => palette.warning, + Severity::Error => palette.danger, + }; + renderer.fill_quad( + renderer::Quad { + bounds: band.underline, + ..Default::default() + }, + color, + ); + } + if self.hover_enabled + && let Some(point) = cursor.position_over(clip) + { + let mut hits: Vec<_> = bands + .iter() + .filter(|band| band.bounds.contains(point)) + .map(|band| band.diagnostic) + .collect(); + hits.sort_unstable(); + hits.dedup(); + hits.sort_by_key(|ordinal| { + ( + match snapshot.diagnostics[*ordinal].diagnostic.severity { + Severity::Error => 0, + Severity::Warning => 1, + }, + *ordinal, + ) + }); + if !hits.is_empty() { + *details = draw_details(renderer, theme, snapshot.diagnostics, &hits, point, clip); + } + } + renderer.end_layer(); + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + self.child.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'b>, + renderer: &R, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.child.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} + +impl<'a, Message: 'a, R> From> for Element<'a, Message, Theme, R> +where + R: text::Renderer< + Font = Font, + Editor = graphics::text::Editor, + Paragraph = graphics::text::Paragraph, + > + 'a, +{ + fn from(value: Decorations<'a, Message, R>) -> Self { + Element::new(value) + } +} + +fn draw_details>( + renderer: &mut R, + theme: &Theme, + diagnostics: &[SourceDiagnostic], + hits: &[usize], + point: Point, + clip: Rectangle, +) -> Option { + const PAD: f32 = 8.; + if clip.width < 100. || clip.height < 80. { + return None; + } + let width = 520_f32.min(clip.width); + let mut details = Vec::new(); + for ordinal in hits.iter().take(4) { + let issue = &diagnostics[*ordinal]; + let d = &issue.diagnostic; + let severity = match d.severity { + Severity::Error => "Error", + Severity::Warning => "Warning", + }; + let raw = format!( + "{severity} [{}] · {}:{}\n{}", + d.code, d.line, d.column, d.message + ); + let mut value: String = raw.chars().take(1000).collect(); + if value.len() < raw.len() { + value.push('…'); + } + if !issue.related.is_empty() { + value.push_str(&format!( + "\n{} related location(s) in Problems", + issue.related.len() + )); + } + details.push(value); + } + let content = details.join("\n\n"); + let paragraph = graphics::text::Paragraph::with_text(text::Text { + content: content.as_str(), + bounds: Size::new(width - PAD * 2., f32::INFINITY), + size: 12.into(), + line_height: text::LineHeight::Relative(1.3), + font: renderer.default_font(), + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::WordOrGlyph, + }); + let remaining = hits.len().saturating_sub(4); + let footer = if remaining > 0 { + format!( + "4 of {} shown · {remaining} more\nFull text: Actions → Source problems", + hits.len() + ) + } else { + "Full text and locations: Actions → Source problems".into() + }; + let footer_paragraph = graphics::text::Paragraph::with_text(text::Text { + content: footer.as_str(), + bounds: Size::new(width - PAD * 2., f32::INFINITY), + size: 11.into(), + line_height: text::LineHeight::Relative(1.2), + font: renderer.default_font(), + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Top, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::WordOrGlyph, + }); + let footer_height = footer_paragraph.min_bounds().height; + let available_body = clip.height - PAD * 2. - footer_height - 4.; + if available_body < 16. { + return None; + } + let max_body_height = + (clip.height * 0.6 - PAD * 2. - footer_height - 4.).clamp(16., available_body); + let body_height = paragraph.min_bounds().height.min(max_body_height); + let height = PAD * 2. + body_height + 4. + footer_height; + let x = point.x.min(clip.x + clip.width - width).max(clip.x); + let y = if point.y + 18. + height <= clip.y + clip.height { + point.y + 18. + } else { + (point.y - 18. - height).max(clip.y) + }; + let bounds = Rectangle::new(Point::new(x, y), Size::new(width, height)); + let palette = theme.extended_palette(); + renderer.fill_quad( + renderer::Quad { + bounds, + border: iced::Border { + color: palette.background.strong.color, + width: 1., + radius: 4.into(), + }, + ..Default::default() + }, + palette.background.weak.color, + ); + let body = Rectangle::new( + Point::new(x + PAD, y + PAD), + Size::new(width - PAD * 2., body_height), + ); + renderer.fill_paragraph( + ¶graph, + body.position(), + palette.background.weak.text, + body, + ); + let footer_bounds = Rectangle::new( + Point::new(x + PAD, body.y + body.height + 4.), + Size::new(width - PAD * 2., footer_height), + ); + renderer.fill_paragraph( + &footer_paragraph, + footer_bounds.position(), + palette.background.weak.text, + footer_bounds, + ); + Some(DetailsParagraphs { + _body: paragraph, + _footer: footer_paragraph, + }) +} + +#[cfg(all(test, debug_assertions))] +mod tests; diff --git a/crates/oa-desktop/src/source_annotations/widget/tests.rs b/crates/oa-desktop/src/source_annotations/widget/tests.rs new file mode 100644 index 00000000..aa1725e7 --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/widget/tests.rs @@ -0,0 +1,1052 @@ +//! Actual Decorations + TextEditor + existing raw-input observer, one retained +//! Tree, graphics Editor/Paragraph, ID-based focus Operation and emitted editor +//! actions. The recorder observes actual fill_editor clusters, renderer layers, +//! and shaped tooltip paragraphs. No private editor-state indexing or fake +//! character-width metrics. Deferred paragraph observations keep only the same +//! Weak used by WGPU and upgrade after draw returns, never a strong test clone. +//! Double/triple click events are batched through the +//! actual widget, then their emitted actions are applied in order to Content. +//! +//! Limits: CPU layout/advance coverage, not raster ink/GPU/native/IME, real App +//! publication admission, menu controller, or the production sensor/view tree. +//! Snapshot and hover_enabled are explicit widget-boundary inputs. Word/Line +//! insertion compares a hovered path with an actual direct-typing control. +//! Footer expectations require measured height and an explicit remaining count. +#![cfg(debug_assertions)] + +use super::{Decorations, Snapshot}; +use crate::views::source_tests::FontLayout; +use iced::advanced::{ + Layout, Shell, Widget, graphics, layout, renderer, + text::{self as shaped, Paragraph as _}, + widget as core_widget, +}; +use iced::widget::text_editor::{self, Action, Content}; +use iced::{ + Background, Color, Element, Event, Font, Padding, Pixels, Point, Rectangle, Size, Theme, + Transformation, keyboard, mouse, window, +}; +use oa_core::{ + Id, + language::{Diagnostic, Span}, + source_project::SourceDiagnostic, + validation::Severity, +}; +use std::{ + collections::BTreeSet, + ops::Range, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +const ORIGIN: Point = Point::new(23., 31.); +const PAD: f32 = 16.; +const EDITOR_ID: &str = "annotation-wrapper-editor"; +const ROW: &str = "alpha beta gamma\n"; + +#[derive(Debug, Clone)] +struct Glyph { + line: usize, + bytes: Range, + bounds: Rectangle, +} + +#[derive(Debug, Clone)] +struct ParagraphPaint { + content: String, + glyphs: Vec, + clip: Rectangle, +} + +#[derive(Debug)] +struct EditorPaint { + glyphs: Vec, + clip: Rectangle, +} + +#[derive(Debug)] +struct QuadPaint { + bounds: Rectangle, + clip: Rectangle, + layer_depth: usize, + background: Background, +} + +#[derive(Debug, Default)] +struct Frame { + editors: Vec, + paragraphs: Vec, + deferred_paragraphs: Vec, + quads: Vec, +} + +impl Frame { + fn deferred_text(&self) -> Vec> { + self.deferred_paragraphs + .iter() + .map(|weak| { + weak.upgrade().map(|paragraph| { + paragraph + .buffer() + .lines + .iter() + .map(|line| line.text()) + .collect::>() + .join("\n") + }) + }) + .collect() + } + fn editor(&self) -> &EditorPaint { + assert_eq!(self.editors.len(), 1, "one actual fill_editor per frame"); + &self.editors[0] + } + fn point(&self, line: usize, byte: usize) -> Point { + let editor = self.editor(); + let glyph = editor + .glyphs + .iter() + .find(|g| g.line == line && g.bytes.contains(&byte) && inside(g.bounds, editor.clip)) + .expect("requested fixture cluster must be genuinely visible"); + Point::new( + glyph.bounds.x + glyph.bounds.width / 2., + glyph.bounds.y + glyph.bounds.height / 2., + ) + } + fn tooltip(&self) -> String { + self.paragraphs + .iter() + .map(|p| p.content.as_str()) + .collect::>() + .join("\n") + } + fn decoration_quads(&self) -> impl Iterator { + self.quads.iter().filter(|q| q.layer_depth > 0) + } +} + +fn inside(bounds: Rectangle, clip: Rectangle) -> bool { + const EPS: f32 = 0.05; + bounds.x >= clip.x - EPS + && bounds.y >= clip.y - EPS + && bounds.x + bounds.width <= clip.x + clip.width + EPS + && bounds.y + bounds.height <= clip.y + clip.height + EPS +} + +struct Recorder { + frame: Frame, + transforms: Vec, + clips: Vec, +} + +impl Recorder { + fn new(viewport: Rectangle) -> Self { + Self { + frame: Frame::default(), + transforms: vec![Transformation::IDENTITY], + clips: vec![viewport], + } + } + fn transform(&self) -> Transformation { + *self.transforms.last().unwrap() + } + fn clip(&self, bounds: Rectangle) -> Rectangle { + self.clips + .last() + .unwrap() + .intersection(&(bounds * self.transform())) + .unwrap_or(Rectangle::with_size(Size::ZERO)) + } + fn glyphs(&self, buffer: &graphics::text::cosmic_text::Buffer, origin: Point) -> Vec { + buffer + .layout_runs() + .flat_map(|run| { + run.glyphs.iter().map(move |glyph| Glyph { + line: run.line_i, + bytes: glyph.start..glyph.end, + // Actual shaped advances and line bands, not estimated ink. + bounds: Rectangle::new( + Point::new(origin.x + glyph.x, origin.y + run.line_top), + Size::new(glyph.w, run.line_height), + ) * self.transform(), + }) + }) + .collect() + } + fn paragraph(&mut self, paragraph: &graphics::text::Paragraph, origin: Point, clip: Rectangle) { + let buffer = paragraph.buffer(); + self.frame.paragraphs.push(ParagraphPaint { + content: buffer + .lines + .iter() + .map(|line| line.text()) + .collect::>() + .join("\n"), + glyphs: self.glyphs(buffer, origin), + clip: self.clip(clip), + }); + } +} + +impl renderer::Renderer for Recorder { + fn start_layer(&mut self, bounds: Rectangle) { + self.clips.push(self.clip(bounds)); + } + fn end_layer(&mut self) { + assert!(self.clips.len() > 1); + self.clips.pop(); + } + fn start_transformation(&mut self, value: Transformation) { + self.transforms.push(self.transform() * value); + } + fn end_transformation(&mut self) { + assert!(self.transforms.len() > 1); + self.transforms.pop(); + } + fn reset(&mut self, bounds: Rectangle) { + *self = Self::new(bounds); + } + fn fill_quad(&mut self, quad: renderer::Quad, background: impl Into) { + self.frame.quads.push(QuadPaint { + bounds: quad.bounds * self.transform(), + clip: *self.clips.last().unwrap(), + layer_depth: self.clips.len() - 1, + background: background.into(), + }); + } + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce(Result) + + Send + + 'static, + ) { + renderer::Renderer::allocate_image(&mut FontLayout, handle, callback); + } +} + +impl shaped::Renderer for Recorder { + type Font = Font; + type Paragraph = graphics::text::Paragraph; + type Editor = graphics::text::Editor; + const ICON_FONT: Font = ::ICON_FONT; + const CHECKMARK_ICON: char = ::CHECKMARK_ICON; + const ARROW_DOWN_ICON: char = ::ARROW_DOWN_ICON; + const SCROLL_UP_ICON: char = ::SCROLL_UP_ICON; + const SCROLL_DOWN_ICON: char = ::SCROLL_DOWN_ICON; + const SCROLL_LEFT_ICON: char = ::SCROLL_LEFT_ICON; + const SCROLL_RIGHT_ICON: char = ::SCROLL_RIGHT_ICON; + const ICED_LOGO: char = ::ICED_LOGO; + fn default_font(&self) -> Font { + shaped::Renderer::default_font(&FontLayout) + } + fn default_size(&self) -> Pixels { + shaped::Renderer::default_size(&FontLayout) + } + fn fill_paragraph( + &mut self, + paragraph: &Self::Paragraph, + origin: Point, + _: Color, + clip: Rectangle, + ) { + self.frame.deferred_paragraphs.push(paragraph.downgrade()); + self.paragraph(paragraph, origin, clip); + } + fn fill_editor(&mut self, editor: &Self::Editor, origin: Point, _: Color, clip: Rectangle) { + self.frame.editors.push(EditorPaint { + glyphs: self.glyphs(editor.buffer(), origin), + clip: self.clip(clip), + }); + } + fn fill_text(&mut self, value: shaped::Text, origin: Point, _: Color, clip: Rectangle) { + let paragraph = graphics::text::Paragraph::with_text(value.as_ref()); + self.paragraph(¶graph, origin, clip); + } +} + +fn diagnostic(path: &str, bytes: Range, code: &str, severity: Severity) -> SourceDiagnostic { + SourceDiagnostic { + path: path.into(), + diagnostic: Diagnostic { + severity, + code: code.into(), + message: format!("Detail {code} 雨"), + object_id: None, + field: None, + line: 1, + column: 1, + span: Span { + start: bytes.start, + end: bytes.end, + }, + }, + related: Vec::new(), + } +} + +struct Mounted { + content: Content, + source: Arc, + path: String, + diagnostics: Vec, + publication: Option, + hover_enabled: bool, + tree: core_widget::Tree, + clock: Arc, + width: f32, + height: f32, + viewport: Option, +} + +impl Mounted { + fn new(source: &str) -> Self { + Self { + content: Content::with_text(source), + source: Arc::from(source), + path: "a".into(), + diagnostics: vec![ + diagnostic("a", 0..5, "A", Severity::Warning), + diagnostic("a", 6..10, "B", Severity::Error), + ], + publication: Some(Id::new_v4()), + hover_enabled: true, + tree: core_widget::Tree::empty(), + clock: Arc::new(AtomicU64::new(0)), + width: 552., + height: 392., + viewport: None, + } + } + fn with_widget( + &mut self, + visit: impl FnOnce( + &mut dyn Widget, + &mut core_widget::Tree, + Layout<'_>, + &mut Recorder, + &Rectangle, + ) -> T, + ) -> T { + let snapshot = self.publication.map(|publication| Snapshot { + publication, + source: self.source.clone(), + path: &self.path, + diagnostics: &self.diagnostics, + }); + let editor = iced::widget::text_editor(&self.content) + .id(EDITOR_ID) + .on_action(|action| action) + .font(Font::MONOSPACE) + .size(14) + .padding(PAD) + .width(self.width) + .height(self.height) + .highlight_with::( + self.source.clone(), + crate::highlight::format, + ); + let decorated = Decorations::new( + editor, + &self.content, + snapshot, + Padding::new(PAD), + self.hover_enabled, + ); + let mut view: Element<'_, Action, Theme, Recorder> = + crate::source_navigation::interaction::observe(decorated.into(), self.clock.clone()); + self.tree.diff(view.as_widget()); + let bounds = Rectangle::new(ORIGIN, Size::new(self.width, self.height)); + let viewport = self.viewport.unwrap_or(bounds); + let mut recorder = Recorder::new(viewport); + let node = view + .as_widget_mut() + .layout( + &mut self.tree, + &recorder, + &layout::Limits::new(Size::ZERO, bounds.size()), + ) + .move_to(ORIGIN); + assert_eq!(Layout::new(&node).bounds(), bounds); + visit( + view.as_widget_mut(), + &mut self.tree, + Layout::new(&node), + &mut recorder, + &viewport, + ) + } + fn draw(&mut self, cursor: mouse::Cursor) -> Frame { + self.with_widget(|widget, tree, layout, recorder, viewport| { + widget.draw( + tree, + recorder, + &Theme::Light, + &renderer::Style::default(), + layout, + cursor, + viewport, + ); + assert_eq!(recorder.clips.len(), 1, "balanced renderer layers"); + assert_eq!( + recorder.transforms.len(), + 1, + "balanced renderer transformations" + ); + std::mem::take(&mut recorder.frame) + }) + } + fn event( + &mut self, + event: &Event, + cursor: mouse::Cursor, + ) -> (Vec, bool, window::RedrawRequest) { + self.with_widget(|widget, tree, layout, renderer, viewport| { + let mut messages = Vec::new(); + let (captured, redraw) = { + let mut shell = Shell::new(&mut messages); + widget.update( + tree, + event, + layout, + cursor, + renderer, + &mut iced::advanced::clipboard::Null, + &mut shell, + viewport, + ); + (shell.is_event_captured(), shell.redraw_request()) + }; + (messages, captured, redraw) + }) + } + fn inspect_focus(&mut self, set: Option) -> bool { + struct Focus { + set: Option, + found: Option, + } + impl core_widget::Operation for Focus { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&core_widget::Id>, + _: Rectangle, + state: &mut dyn core_widget::operation::Focusable, + ) { + if id == Some(&core_widget::Id::new(EDITOR_ID)) { + assert!(self.found.is_none(), "duplicate editor ID"); + if let Some(focus) = self.set { + if focus { + state.focus(); + } else { + state.unfocus(); + } + } + self.found = Some(state.is_focused()); + } + } + } + let mut operation = Focus { set, found: None }; + self.with_widget(|widget, tree, layout, renderer, _| { + widget.operate(tree, layout, renderer, &mut operation) + }); + operation + .found + .expect("actual editor is reachable by ID through all wrappers") + } + fn state(&mut self) -> ContentState { + let focused = self.inspect_focus(None); + ContentState { + text: self.content.text(), + selected: self.content.selection(), + cursor: self.content.cursor(), + scroll: self + .content + .with_editor(|e| (e.buffer().scroll().line, e.buffer().scroll().vertical)), + focused, + clock: self.clock.load(Ordering::SeqCst), + } + } + fn gesture(&mut self, point: Point, clicks: usize) { + let messages = self.with_widget(|widget, tree, layout, renderer, viewport| { + let mut messages = Vec::new(); + for _ in 0..clicks { + for event in [ + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ] { + let mut shell = Shell::new(&mut messages); + widget.update( + tree, + &event, + layout, + mouse::Cursor::Available(point), + renderer, + &mut iced::advanced::clipboard::Null, + &mut shell, + viewport, + ); + } + } + messages + }); + assert_eq!(messages.len(), clicks); + assert!(matches!(messages[0], Action::Click(_))); + assert!(matches!(messages[1], Action::SelectWord)); + if clicks == 3 { + assert!(matches!(messages[2], Action::SelectLine)); + } + for message in messages { + self.content.perform(message); + } + assert!(self.inspect_focus(None)); + } + fn type_x(&mut self) { + let event = Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character("x".into()), + modified_key: keyboard::Key::Character("x".into()), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyX), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: Some("X".into()), + repeat: false, + }); + let (messages, captured, _) = self.event(&event, mouse::Cursor::Unavailable); + assert!(captured); + assert_eq!(messages.len(), 1); + assert!(matches!( + messages[0], + Action::Edit(text_editor::Edit::Insert('X')) + )); + for action in messages { + self.content.perform(action); + } + } +} + +#[derive(Debug, PartialEq)] +struct ContentState { + text: String, + selected: Option, + cursor: text_editor::Cursor, + scroll: (usize, f32), + focused: bool, + clock: u64, +} + +fn passive(mounted: &mut Mounted, point: Option) -> Frame { + let before = mounted.state(); + let (event, cursor) = match point { + Some(point) => ( + Event::Mouse(mouse::Event::CursorMoved { position: point }), + mouse::Cursor::Available(point), + ), + None => ( + Event::Mouse(mouse::Event::CursorLeft), + mouse::Cursor::Unavailable, + ), + }; + let (messages, captured, redraw) = mounted.event(&event, cursor); + assert!( + messages.is_empty(), + "hover must not synthesize editor actions" + ); + assert!( + !captured, + "hover must not consume ordinary pointer movement" + ); + assert_eq!(redraw, window::RedrawRequest::NextFrame); + let frame = mounted.draw(cursor); + assert_eq!( + mounted.state(), + before, + "hover must preserve exact content, hidden selection, focus, scroll and raw clock" + ); + frame +} + +#[test] +fn pointer_motion_between_bands_blank_and_leave_is_passive_and_always_redraws() { + let mut mounted = Mounted::new(&ROW.repeat(24)); + mounted.diagnostics = (0..24) + .flat_map(|line| { + let start = line * ROW.len(); + [ + diagnostic("a", start..start + 5, "A", Severity::Warning), + diagnostic("a", start + 6..start + 10, "B", Severity::Error), + ] + }) + .collect(); + assert!(mounted.inspect_focus(Some(true))); + mounted.draw(mouse::Cursor::Unavailable); + mounted.content.perform(Action::Scroll { lines: 2 }); + let frame = mounted.draw(mouse::Cursor::Unavailable); + let line = frame + .editor() + .glyphs + .iter() + .find(|g| g.bytes.contains(&2) && inside(g.bounds, frame.editor().clip)) + .unwrap() + .line; + assert!(mounted.state().scroll != (0, 0.)); + let a = frame.point(line, 2); + let b = frame.point(line, 7); + let clip = frame.editor().clip; + let blank = Point::new(clip.x + clip.width - 3., a.y); + let frame_a = passive(&mut mounted, Some(a)); + assert!(frame_a.tooltip().contains("Detail A 雨")); + assert!(!frame_a.tooltip().contains("Detail B 雨")); + let frame_b = passive(&mut mounted, Some(b)); + assert!(frame_b.tooltip().contains("Detail B 雨")); + assert!(!frame_b.tooltip().contains("Detail A 雨")); + assert!(passive(&mut mounted, Some(blank)).paragraphs.is_empty()); + assert!(passive(&mut mounted, None).paragraphs.is_empty()); + // Unfocused motion also requests repaint, without depending on caret blink. + assert!(!mounted.inspect_focus(Some(false))); + assert!( + passive(&mut mounted, Some(a)) + .tooltip() + .contains("Detail A 雨") + ); +} + +#[test] +fn actual_word_and_line_selection_then_typing_survive_hover_and_publication_changes() { + for clicks in [2, 3] { + let run = |hover: bool| { + let mut mounted = Mounted::new("alpha beta gamma\r\nsecond line 雨\r\n"); + let frame = mounted.draw(mouse::Cursor::Unavailable); + let point = frame.point(0, 2); + mounted.gesture(point, clicks); + let selected = if clicks == 2 { + "alpha" + } else { + "alpha beta gamma" + }; + assert_eq!(mounted.content.selection().as_deref(), Some(selected)); + mounted.draw(mouse::Cursor::Unavailable); + let before = mounted.state(); + if hover { + passive(&mut mounted, Some(point)); + mounted.publication = None; + assert!( + mounted + .draw(mouse::Cursor::Available(point)) + .paragraphs + .is_empty() + ); + mounted.publication = Some(Id::new_v4()); + assert!( + mounted + .draw(mouse::Cursor::Available(point)) + .tooltip() + .contains("Detail A") + ); + passive(&mut mounted, None); + assert_eq!(mounted.state(), before); + } + mounted.type_x(); + mounted.content.text() + }; + let control = run(false); + let hovered = run(true); + let expected = if clicks == 2 { + "X beta gamma\r\nsecond line 雨\r\n" + } else { + "X\r\nsecond line 雨\r\n" + }; + assert_eq!(control, expected, "actual direct-typing control"); + assert_eq!( + hovered, expected, + "hover must preserve the effective selected bytes, not only raw cursor endpoints" + ); + } +} + +#[test] +fn stationary_pointer_replacement_absence_and_path_switch_use_only_current_index() { + let mut mounted = Mounted::new(ROW); + mounted.diagnostics = vec![ + diagnostic("a", 0..5, "A", Severity::Warning), + diagnostic("b", 0..5, "OTHER_PATH", Severity::Error), + ]; + assert!(mounted.inspect_focus(Some(true))); + let frame = mounted.draw(mouse::Cursor::Unavailable); + let alpha = frame.point(0, 2); + let beta = frame.point(0, 7); + let before = mounted.state(); + assert!( + mounted + .draw(mouse::Cursor::Available(alpha)) + .tooltip() + .contains("Detail A") + ); + // Same publication and same source Arc; only the active logical path changes. + mounted.path = "b".into(); + let other = mounted.draw(mouse::Cursor::Available(alpha)); + assert!(other.tooltip().contains("OTHER_PATH")); + assert!(!other.tooltip().contains("Detail A")); + mounted.path = "a".into(); + assert!( + mounted + .draw(mouse::Cursor::Available(alpha)) + .tooltip() + .contains("Detail A") + ); + // Same path/source Arc but a different publication moves the diagnostic. + mounted.publication = Some(Id::new_v4()); + mounted.diagnostics = vec![diagnostic("a", 6..10, "NEW_BETA", Severity::Error)]; + assert!( + mounted + .draw(mouse::Cursor::Available(alpha)) + .paragraphs + .is_empty() + ); + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .tooltip() + .contains("NEW_BETA") + ); + mounted.publication = None; + let empty = mounted.draw(mouse::Cursor::Available(beta)); + assert!(empty.paragraphs.is_empty()); + assert_eq!(empty.decoration_quads().count(), 0); + mounted.publication = Some(Id::new_v4()); + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .tooltip() + .contains("NEW_BETA") + ); + assert_eq!(mounted.state(), before); +} + +#[test] +fn disabled_hover_and_unavailable_cursor_hide_details_without_removing_current_marks() { + let mut mounted = Mounted::new(ROW); + let frame = mounted.draw(mouse::Cursor::Unavailable); + let point = frame.point(0, 2); + assert!( + mounted + .draw(mouse::Cursor::Available(point)) + .tooltip() + .contains("Detail A") + ); + let before = mounted.state(); + mounted.hover_enabled = false; + let disabled = mounted.draw(mouse::Cursor::Available(point)); + assert!(disabled.paragraphs.is_empty()); + assert!(disabled.decoration_quads().count() > 0); + mounted.hover_enabled = true; + let unavailable = mounted.draw(mouse::Cursor::Unavailable); + assert!(unavailable.paragraphs.is_empty()); + assert!(unavailable.decoration_quads().count() > 0); + assert_eq!(mounted.state(), before); +} + +fn paragraph_complete(paint: &ParagraphPaint, clip: Rectangle) -> bool { + let lines: Vec<_> = paint.content.split('\n').collect(); + let wanted: BTreeSet<_> = lines + .iter() + .enumerate() + .flat_map(|(line, text)| { + text.char_indices() + .filter(|(_, c)| !c.is_whitespace()) + .map(move |(byte, _)| (line, byte)) + }) + .collect(); + let mut covered = BTreeSet::new(); + for glyph in &paint.glyphs { + let Some(text) = lines.get(glyph.line) else { + return false; + }; + let Some(cluster) = text.get(glyph.bytes.clone()) else { + return false; + }; + if cluster.chars().all(char::is_whitespace) { + continue; + } + if !inside(glyph.bounds, clip) { + return false; + } + for (byte, character) in text.char_indices() { + if !character.is_whitespace() && glyph.bytes.contains(&byte) { + covered.insert((glyph.line, byte)); + } + } + } + !wanted.is_empty() && covered == wanted +} + +#[test] +fn deferred_paragraph_renderer_rejects_the_old_local_lifetime() { + let mut renderer = Recorder::new(Rectangle::with_size(Size::new(300., 200.))); + { + // Exact lifetime mechanism of the rejected local draw_details: queue + // the real graphics Weak, then destroy its only strong owner on return. + let local = graphics::text::Paragraph::with_text(shaped::Text { + content: "Visible during draw; absent during WGPU prepare", + bounds: Size::new(280., 180.), + size: 14.into(), + line_height: shaped::LineHeight::Relative(1.3), + font: Font::MONOSPACE, + align_x: shaped::Alignment::Left, + align_y: iced::alignment::Vertical::Top, + shaping: shaped::Shaping::Advanced, + wrapping: shaped::Wrapping::WordOrGlyph, + }); + shaped::Renderer::fill_paragraph( + &mut renderer, + &local, + Point::ORIGIN, + Color::BLACK, + Rectangle::with_size(Size::new(300., 200.)), + ); + assert!(renderer.frame.deferred_text()[0].is_some()); + } + assert!( + !renderer.frame.paragraphs[0].glyphs.is_empty(), + "the old eager recorder sees real shaped text and would miss this defect" + ); + assert_eq!( + renderer.frame.deferred_text(), + vec![None], + "WGPU cannot upgrade the queued weak paragraph after the local owner drops" + ); +} + +#[test] +fn deferred_tooltip_text_survives_draw_and_only_current_authority_is_retained() { + fn live(frame: &Frame, body: &str) { + let deferred = frame.deferred_text(); + assert_eq!(deferred.len(), 2, "body and footer are queued separately"); + assert!( + deferred.iter().all(Option::is_some), + "both real weak paragraphs must upgrade AFTER widget draw returns" + ); + assert!(deferred[0].as_ref().unwrap().contains(body)); + assert!( + deferred[1] + .as_ref() + .unwrap() + .contains("Actions → Source problems") + ); + let immediate: Vec<_> = frame + .paragraphs + .iter() + .map(|p| Some(p.content.clone())) + .collect(); + assert_eq!( + deferred, immediate, + "deferred renderer consumes the current shaped text" + ); + } + fn retired(frame: &Frame) { + assert_eq!( + frame.deferred_text(), + vec![None, None], + "no previous-frame paragraph ownership leaks across a replacement or hidden draw" + ); + } + let mut mounted = Mounted::new(ROW); + let initial = mounted.draw(mouse::Cursor::Unavailable); + let alpha = initial.point(0, 2); + let beta = initial.point(0, 7); + let before = mounted.state(); + let first = mounted.draw(mouse::Cursor::Available(alpha)); + live(&first, "Detail A"); + assert_eq!(mounted.state(), before); + // Rebuilding the Element / operating the actual editor does not drop Tree + // ownership before the renderer's later preparation stage. + live(&first, "Detail A"); + + mounted.publication = Some(Id::new_v4()); + mounted.diagnostics = vec![ + diagnostic("a", 6..10, "NEW_BETA", Severity::Error), + diagnostic("b", 6..10, "OTHER_PATH", Severity::Warning), + ]; + let now_blank = mounted.draw(mouse::Cursor::Available(alpha)); + assert!(now_blank.deferred_paragraphs.is_empty()); + retired(&first); + let replacement = mounted.draw(mouse::Cursor::Available(beta)); + live(&replacement, "NEW_BETA"); + mounted.path = "b".into(); + let other = mounted.draw(mouse::Cursor::Available(beta)); + live(&other, "OTHER_PATH"); + retired(&replacement); + mounted.publication = None; + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .deferred_paragraphs + .is_empty() + ); + retired(&other); + + mounted.publication = Some(Id::new_v4()); + let shown = mounted.draw(mouse::Cursor::Available(beta)); + live(&shown, "OTHER_PATH"); + mounted.hover_enabled = false; + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .deferred_paragraphs + .is_empty() + ); + retired(&shown); + mounted.hover_enabled = true; + let shown = mounted.draw(mouse::Cursor::Available(beta)); + live(&shown, "OTHER_PATH"); + assert!( + mounted + .draw(mouse::Cursor::Unavailable) + .deferred_paragraphs + .is_empty() + ); + retired(&shown); + + let shown = mounted.draw(mouse::Cursor::Available(beta)); + live(&shown, "OTHER_PATH"); + mounted.viewport = Some(Rectangle::new(Point::new(-10., -10.), Size::new(1., 1.))); + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .deferred_paragraphs + .is_empty() + ); + retired(&shown); + mounted.viewport = None; + let shown = mounted.draw(mouse::Cursor::Available(beta)); + live(&shown, "OTHER_PATH"); + mounted.source = Arc::from("different current snapshot"); + assert!( + mounted + .draw(mouse::Cursor::Available(beta)) + .deferred_paragraphs + .is_empty() + ); + retired(&shown); + mounted.source = Arc::from(ROW); + let shown = mounted.draw(mouse::Cursor::Available(beta)); + live(&shown, "OTHER_PATH"); + assert_eq!( + mounted.state(), + before, + "retention must not alter editor state or raw input clock" + ); + drop(mounted); + retired(&shown); +} + +#[test] +fn measured_footer_and_omitted_count_fit_actual_shaped_widths_and_ancestor_clip() { + for width in [100., 180., 520.] { + let mut mounted = Mounted::new(ROW); + mounted.width = width + PAD * 2.; + mounted.height = 432.; + mounted.diagnostics = (0..6) + .map(|n| { + let mut value = diagnostic( + "a", + 0..5, + &format!("OVERLAP_{n}"), + if n == 0 { + Severity::Warning + } else { + Severity::Error + }, + ); + value.diagnostic.message = + format!("Long 雨 message {}", "bounded details ".repeat(90)); + value + }) + .collect(); + let first = mounted.draw(mouse::Cursor::Unavailable); + let point = first.point(0, 2); + let frame = mounted.draw(mouse::Cursor::Available(point)); + let footer = frame + .paragraphs + .iter() + .find(|p| p.content.contains("4 of 6 shown")) + .expect("overlap footer must exist at admitted test viewport"); + assert!(footer.content.contains("2 more")); + assert!(footer.content.contains("Actions → Source problems")); + assert!( + paragraph_complete(footer, footer.clip), + "all shaped footer clusters must fit at width {width}: {footer:?}" + ); + let body = frame + .paragraphs + .iter() + .find(|p| p.content.contains("OVERLAP_")) + .unwrap(); + assert!( + body.content.starts_with("Error [OVERLAP_1]"), + "errors precede warning even when warning was published first" + ); + assert!(!body.content.contains("OVERLAP_0")); + for quad in frame.decoration_quads() { + assert!( + inside(quad.bounds, quad.clip), + "decoration outside ancestor layer: {quad:?}" + ); + } + let warning = Background::Color(Theme::Light.palette().warning); + let error = Background::Color(Theme::Light.palette().danger); + let colors: Vec<_> = frame + .decoration_quads() + .filter(|q| q.background == warning || q.background == error) + .map(|q| q.background) + .collect(); + assert_eq!(colors.len(), 6); + assert_eq!( + colors[0], warning, + "warnings painted below overlapping errors" + ); + assert!(colors[1..].iter().all(|color| *color == error)); + if width == 100. { + // In-process rejected old budget: same actual shaped footer clipped + // to the old 26px allowance must fail our completeness evaluator. + assert!(footer.clip.height > 26.); + assert!(!paragraph_complete( + footer, + Rectangle { + height: 26., + ..footer.clip + } + )); + } + // A genuinely narrower ancestor viewport, not a nominal child width. + let inset = if width > 100. { 9. } else { 0. }; + mounted.viewport = Some(Rectangle::new( + Point::new(ORIGIN.x + PAD + inset, ORIGIN.y + PAD), + Size::new(width - inset, 220.), + )); + let clipped = mounted.draw(mouse::Cursor::Available(point)); + for quad in clipped.decoration_quads() { + assert!(inside(quad.bounds, quad.clip)); + } + let footer = clipped + .paragraphs + .iter() + .find(|p| p.content.contains("4 of 6 shown")) + .expect("footer must remain usable at 220px effective height"); + assert!(paragraph_complete(footer, footer.clip)); + mounted.viewport = Some(Rectangle::new( + Point::new(ORIGIN.x + PAD, ORIGIN.y + PAD), + Size::new(width, 60.), + )); + assert!( + mounted + .draw(mouse::Cursor::Available(point)) + .paragraphs + .is_empty(), + "insufficient tooltip height is suppressed safely" + ); + } +} diff --git a/crates/oa-desktop/src/views/text_workspace.rs b/crates/oa-desktop/src/views/text_workspace.rs index c52d6057..f79bbd0a 100644 --- a/crates/oa-desktop/src/views/text_workspace.rs +++ b/crates/oa-desktop/src/views/text_workspace.rs @@ -15,6 +15,7 @@ pub(crate) const STRIP_HEIGHT: f32 = 42.; pub(crate) const FIND_HEIGHT: f32 = 36.; pub(crate) const DRAWER_HEIGHT: f32 = 180.; pub(crate) const STATUS_HEIGHT: f32 = 22.; +const EDITOR_PADDING: f32 = 16.; /// The caller supplies one persistent diagnostic Guard, including when hidden /// (None publication and empty child). References is empty while hidden; its @@ -62,12 +63,19 @@ pub(super) fn view<'a>( }) .font(Font::MONOSPACE) .size(14) - .padding(16) + .padding(EDITOR_PADDING) .height(Fill) .highlight_with::( app.source_snapshot.clone(), crate::highlight::format, ); + let editor = crate::source_annotations::Decorations::new( + editor, + &app.source_editor, + app.source_annotation_snapshot(), + iced::Padding::new(EDITOR_PADDING), + app.source_navigation_available(), + ); let editor = widget::sensor(editor) .key(( app.source_workspace.active.clone(), diff --git a/crates/vendor/iced_widget/.cargo_vcs_info.json b/crates/vendor/iced_widget/.cargo_vcs_info.json new file mode 100644 index 00000000..3afb88d3 --- /dev/null +++ b/crates/vendor/iced_widget/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "54abf81d13fec06d4d9ac754b03ce2c3313e8a1f" + }, + "path_in_vcs": "widget" +} \ No newline at end of file diff --git a/crates/vendor/iced_widget/ARCADIA-PATCH.md b/crates/vendor/iced_widget/ARCADIA-PATCH.md new file mode 100644 index 00000000..e4c1ef0c --- /dev/null +++ b/crates/vendor/iced_widget/ARCADIA-PATCH.md @@ -0,0 +1,31 @@ +# Pinned Iced widget patch + +Upstream crate: iced_widget 0.14.2, MIT. Its registry VCS record identifies +`iced-rs/iced` commit `54abf81d13fec06d4d9ac754b03ce2c3313e8a1f`, path `widget`. +The accompanying LICENSE was obtained from that exact upstream commit: +https://raw.githubusercontent.com/iced-rs/iced/54abf81d13fec06d4d9ac754b03ce2c3313e8a1f/LICENSE +SHA-256 `fc9086ba4eba4b77e4c603906362742bc3715ffa2c7810c6074fe7780d3a0398`. + +All copied src/assets and Cargo manifests retain their upstream bytes except +`src/text_editor.rs`, which adds one documented read-only `Content::with_editor` +callback. Original file SHA-256: +`cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1`. +The callback borrows the existing renderer editor and releases the borrow when +the closure returns. It adds no mutable editor access or event behavior. + +ARCADIA uses this seam to project source diagnostics onto the actual painted +editor layout. It must not clone/rebuild Content or move user selection for +measurement. The isolated Windows CPU experiment verified the exact painted +editor identity and geometry plus retained Word/Line selection and scroll; +application integration/native validation are separately required. + +UPSTREAM-MANIFEST.json records all files from the local pinned registry source, +including registry bookkeeping and the upstream crate lockfile not copied here. +Those omitted files are not package inputs. The workspace root Cargo.lock and +local patch path identify the resolved dependency; this patched package is not +the unmodified registry checksum. Keep LICENSE and this provenance in source +captures and generated dependency notices. + +On upgrades compare the pristine upstream module and replay only this accessor. +Recheck actual editor layout, selection/input continuity and annotation tests. +Remove this patch if upstream provides equivalent read-only layout access. diff --git a/crates/vendor/iced_widget/Cargo.toml b/crates/vendor/iced_widget/Cargo.toml new file mode 100644 index 00000000..e7c5b0db --- /dev/null +++ b/crates/vendor/iced_widget/Cargo.toml @@ -0,0 +1,126 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "iced_widget" +version = "0.14.2" +authors = ["Héctor Ramón Jiménez "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "The built-in widgets for iced" +homepage = "https://iced.rs" +readme = false +keywords = [ + "gui", + "ui", + "graphics", + "interface", + "widgets", +] +categories = ["gui"] +license = "MIT" +repository = "https://github.com/iced-rs/iced" + +[package.metadata.docs.rs] +rustdoc-args = [ + "--cfg", + "docsrs", +] +all-features = true + +[features] +advanced = [] +canvas = ["iced_renderer/geometry"] +crisp = [] +highlighter = ["dep:iced_highlighter"] +image = ["iced_renderer/image"] +lazy = ["ouroboros"] +markdown = ["dep:pulldown-cmark"] +qr_code = [ + "canvas", + "dep:qrcode", +] +svg = ["iced_renderer/svg"] +wgpu = ["iced_renderer/wgpu-bare"] + +[lib] +name = "iced_widget" +path = "src/lib.rs" + +[dependencies.iced_highlighter] +version = "0.14.0" +optional = true + +[dependencies.iced_renderer] +version = "0.14.0" + +[dependencies.log] +version = "0.4" + +[dependencies.num-traits] +version = "0.2" + +[dependencies.ouroboros] +version = "0.18" +optional = true + +[dependencies.pulldown-cmark] +version = "0.12" +optional = true + +[dependencies.qrcode] +version = "0.13" +optional = true +default-features = false + +[dependencies.rustc-hash] +version = "2.0" + +[dependencies.thiserror] +version = "2" + +[dependencies.unicode-segmentation] +version = "1.0" + +[lints.clippy] +default_trait_access = "deny" +filter_map_next = "deny" +from_over_into = "deny" +large-enum-variant = "allow" +manual_let_else = "deny" +map-entry = "allow" +match-wildcard-for-single-variants = "deny" +needless_borrow = "deny" +new_without_default = "deny" +redundant-closure-for-method-calls = "deny" +result_large_err = "allow" +semicolon_if_nothing_returned = "deny" +trivially-copy-pass-by-ref = "deny" +type-complexity = "allow" +unused_async = "deny" +useless_conversion = "deny" + +[lints.rust] +missing_docs = "deny" +unsafe_code = "deny" +unused_results = "deny" + +[lints.rust.rust_2018_idioms] +level = "deny" +priority = -1 + +[lints.rustdoc] +broken_intra_doc_links = "forbid" diff --git a/crates/vendor/iced_widget/Cargo.toml.orig b/crates/vendor/iced_widget/Cargo.toml.orig new file mode 100644 index 00000000..aa987c2c --- /dev/null +++ b/crates/vendor/iced_widget/Cargo.toml.orig @@ -0,0 +1,51 @@ +[package] +name = "iced_widget" +description = "The built-in widgets for iced" +version = "0.14.2" +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +categories.workspace = true +keywords.workspace = true + +[lints] +workspace = true + +[package.metadata.docs.rs] +rustdoc-args = ["--cfg", "docsrs"] +all-features = true + +[features] +lazy = ["ouroboros"] +image = ["iced_renderer/image"] +svg = ["iced_renderer/svg"] +canvas = ["iced_renderer/geometry"] +qr_code = ["canvas", "dep:qrcode"] +wgpu = ["iced_renderer/wgpu-bare"] +markdown = ["dep:pulldown-cmark"] +highlighter = ["dep:iced_highlighter"] +advanced = [] +crisp = [] + +[dependencies] +iced_renderer.workspace = true + +num-traits.workspace = true +log.workspace = true +rustc-hash.workspace = true +thiserror.workspace = true +unicode-segmentation.workspace = true + +ouroboros.workspace = true +ouroboros.optional = true + +qrcode.workspace = true +qrcode.optional = true + +pulldown-cmark.workspace = true +pulldown-cmark.optional = true + +iced_highlighter.workspace = true +iced_highlighter.optional = true diff --git a/crates/vendor/iced_widget/LICENSE b/crates/vendor/iced_widget/LICENSE new file mode 100644 index 00000000..f1148e26 --- /dev/null +++ b/crates/vendor/iced_widget/LICENSE @@ -0,0 +1,18 @@ +Copyright 2019 Héctor Ramón, Iced contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/iced_widget/UPSTREAM-MANIFEST.json b/crates/vendor/iced_widget/UPSTREAM-MANIFEST.json new file mode 100644 index 00000000..5e268a0e --- /dev/null +++ b/crates/vendor/iced_widget/UPSTREAM-MANIFEST.json @@ -0,0 +1,352 @@ +[ + { + "path": ".cargo-ok", + "bytes": 7, + "sha256": "afbf9d0f3560b0fd7795e81c42a0a79ee6b6fc67e064f77826aee642cad28d91" + }, + { + "path": ".cargo_vcs_info.json", + "bytes": 100, + "sha256": "58c111ee6197295ae381d021ab7afd7ac38b02acce90d0ff0f82ae2e1e5f864c" + }, + { + "path": "assets/iced-logo.svg", + "bytes": 1892, + "sha256": "8ac9e30de1b466b3d594c77c984299b1a229f6f0f010e6804139a2f41ea8345e" + }, + { + "path": "Cargo.lock", + "bytes": 56700, + "sha256": "89fe393e9bbc1c6dc94796d764dfb3f864adb400607b4508b6138eb3205cce37" + }, + { + "path": "Cargo.toml", + "bytes": 2622, + "sha256": "e8e6b9a72fc0f97c84b8fb2703fe6cdccd738c6c01d2fadf018a012182de93b5" + }, + { + "path": "Cargo.toml.orig", + "bytes": 1109, + "sha256": "f9bed37f4aa1edebea47824c1f9678411d410a1546401a14ce6f8b4108c96448" + }, + { + "path": "src/action.rs", + "bytes": 2507, + "sha256": "feb91a386073280d76eaf37dbb8e7380e5a6bf9b8003110107b32bb8f577a239" + }, + { + "path": "src/button.rs", + "bytes": 21150, + "sha256": "a8019fee3ad691fb2395afcdf302bec5f9e1cc592552aff4d8c8bbd1c6149f5a" + }, + { + "path": "src/canvas/program.rs", + "bytes": 3099, + "sha256": "637bbc6d10a623f355c6d44af1162a10331861806cec094e2a8f2a97313acab9" + }, + { + "path": "src/canvas.rs", + "bytes": 9637, + "sha256": "1fecfd50d593c5ff9bc9317c9f1624d0c22eddc7cf39a795684bf70eb5954acd" + }, + { + "path": "src/checkbox.rs", + "bytes": 20973, + "sha256": "4409c11d6473baf4baefafbb3ed253cd9353f04136cac6009422a876a84ec6b1" + }, + { + "path": "src/column.rs", + "bytes": 17490, + "sha256": "f880d15ebdb33e2f6ebb6067bb0b124c8fafdc73a6e5112a6f5b5e012fddadf4" + }, + { + "path": "src/combo_box.rs", + "bytes": 30694, + "sha256": "973901d05003f684be1d13da5adc88ef7e030dc31db07c1bb0717cf9210d2391" + }, + { + "path": "src/container.rs", + "bytes": 18126, + "sha256": "f8162a4e1c25c602070da482ecccfd5e62f456ffe1caa5394a11243d339f6114" + }, + { + "path": "src/float.rs", + "bytes": 12092, + "sha256": "9bc2d313052c97338dcdec2a5be7e0301b6261fe7975b511050fe4abeeb1c571" + }, + { + "path": "src/grid.rs", + "bytes": 11862, + "sha256": "01e4deea4638fba19e3859d7ae27bd8090bca3fcee6f31c48bf5ad9a19f5c151" + }, + { + "path": "src/helpers.rs", + "bytes": 60874, + "sha256": "3989cad7e901ff8171a16024d93ec40cb32e7859c2e76a98359f5f230a2fabcb" + }, + { + "path": "src/image/viewer.rs", + "bytes": 14066, + "sha256": "ce372e8ba97248cea915abd52046020c0f5c318efc2af1d7f4d74958b14629bf" + }, + { + "path": "src/image.rs", + "bytes": 12361, + "sha256": "dab4c761c846ba2006ba5298c9b3573b0a2787272b2f74376cde6c35ae7f3694" + }, + { + "path": "src/keyed/column.rs", + "bytes": 10710, + "sha256": "09adcae868d561443e8d8b6f3cdfefd6831d8e94adf2b22c551c85b9669eb620" + }, + { + "path": "src/keyed.rs", + "bytes": 2840, + "sha256": "f1e572a13d0717816a6d4467d14f07fa613956a6bf9a2b234d79789b0bcb1d57" + }, + { + "path": "src/lazy/cache.rs", + "bytes": 377, + "sha256": "59445853fb59941d73904689e5ee34bc6e9f7159c58bbea0cac8ef87f69a1b10" + }, + { + "path": "src/lazy/component.rs", + "bytes": 20440, + "sha256": "05e4e793f3462e5f164ef1191a5cf2c6daf32d410aeebbe1bb4e951fb04fdef7" + }, + { + "path": "src/lazy/helpers.rs", + "bytes": 1292, + "sha256": "d6bab15685469619bc3833afacc1df22112adacbdf944d6dbb5ea1b9e5f9d993" + }, + { + "path": "src/lazy.rs", + "bytes": 11530, + "sha256": "8c127fcd044cf887564856ce3a38ecf41ff3ff24d832d72095ec4e92f9907673" + }, + { + "path": "src/lib.rs", + "bytes": 2933, + "sha256": "4da7f1a83cd8edc30bd9983e59ba3537f44a475814d0b9c80dcf7e97873badb4" + }, + { + "path": "src/markdown.rs", + "bytes": 50899, + "sha256": "5269582001d26d79f9de4e6ee60ebed587adf552f6f91c8ffcd757782319d161" + }, + { + "path": "src/mouse_area.rs", + "bytes": 12666, + "sha256": "01686e65f76a4b89356ec6e5900b4411c856b15a56236ed3f6ef410ec3a4983d" + }, + { + "path": "src/overlay/menu.rs", + "bytes": 19043, + "sha256": "89719b05b75b4b1382de49e1220cab0034b155a24d744509ad928a25d69cca57" + }, + { + "path": "src/overlay.rs", + "bytes": 72, + "sha256": "104d339ba61626ebf0877f5c942e9f67b3db6261d6c9ae5e4805859508e1bc49" + }, + { + "path": "src/pane_grid/axis.rs", + "bytes": 7860, + "sha256": "4e7a842bc60bee64ee86c4d512c0ac034e4c6bf16b1fe0777c16b8a0b5b28751" + }, + { + "path": "src/pane_grid/configuration.rs", + "bytes": 609, + "sha256": "0d70a26e2fcafc1f1403e43f6c0790698940c92a0966d4ea4acec216778d65cd" + }, + { + "path": "src/pane_grid/content.rs", + "bytes": 11886, + "sha256": "13ec6f9517e0cbd7d2a311ef57b963eff2c5fe6f05264cca29e54d14a0ea8151" + }, + { + "path": "src/pane_grid/controls.rs", + "bytes": 1591, + "sha256": "c7eeffce253b8aa0e7853baaf33ab3e926261df7e6b809a566aa17a4e779d560" + }, + { + "path": "src/pane_grid/direction.rs", + "bytes": 186, + "sha256": "f79259afb26fb38f4af7efdaea5fc6527b5426fe195ef89f72d16405d0bc3076" + }, + { + "path": "src/pane_grid/draggable.rs", + "bytes": 290, + "sha256": "3688ee1a543a281c036d2f4f156a3c529263219b50e3945ef50fa4bb63b02ae3" + }, + { + "path": "src/pane_grid/node.rs", + "bytes": 10452, + "sha256": "bcda8a561581849163a3bb75f34d310d6463ff58ea6660e9fa33c8ea22d40c9f" + }, + { + "path": "src/pane_grid/pane.rs", + "bytes": 209, + "sha256": "2305c36971b405368a99394ad6b3cd0cf21638d9933f8cbd445fd764a257a4df" + }, + { + "path": "src/pane_grid/split.rs", + "bytes": 221, + "sha256": "8be472fc48c37055bbb62395839c599559abc3d70cb7806892f869bd95e90804" + }, + { + "path": "src/pane_grid/state.rs", + "bytes": 14109, + "sha256": "a13125af64ea6da1bcb5a13469f4ffdd51106776b27871e10ce3ac037d865b63" + }, + { + "path": "src/pane_grid/title_bar.rs", + "bytes": 20398, + "sha256": "7556b48c85811d64e3c11c4ce901c579568f4ec056ff5f1bd5e38066b2f059ce" + }, + { + "path": "src/pane_grid.rs", + "bytes": 42574, + "sha256": "4eb5ae3be82237f5193b550d407c1a5a9f099a4d554442dbe8f51b0b72edb213" + }, + { + "path": "src/pick_list.rs", + "bytes": 27489, + "sha256": "449fbbc5342c2bed227ec2587111ab43dcecb7382001fbcba2fab2651e54b33b" + }, + { + "path": "src/pin.rs", + "bytes": 7207, + "sha256": "f0b0ab2089300c9ceab8f8f3c75cd96409c7a84f3fa26d68645aab3f92247426" + }, + { + "path": "src/progress_bar.rs", + "bytes": 8776, + "sha256": "38e9bb585a9b764ff6cb947e17d65259a6a32cf3a69b23844e4c28e6c5a1c3ba" + }, + { + "path": "src/qr_code.rs", + "bytes": 12261, + "sha256": "712482a35eab47e0559fafd5452a2b934c5fbc5ae07c14c0c71781713b4dbfcc" + }, + { + "path": "src/radio.rs", + "bytes": 15482, + "sha256": "7b0f42744416e07f8af9409c5bae997b07a0653492ccc2e71911bdeed9c747b2" + }, + { + "path": "src/responsive.rs", + "bytes": 5358, + "sha256": "2a3ff68573825a1407e3be27adbfb4851c7aae7cd100c7a0b670911bc3a08393" + }, + { + "path": "src/row.rs", + "bytes": 16919, + "sha256": "2566e8868bced1a6b7d455c9f1ab61c7025772b27bf843ee806d5c7806fcd44b" + }, + { + "path": "src/rule.rs", + "bytes": 8599, + "sha256": "b7b23722d314864d712e21c8ef260d2b747d0ac795bb3263a0a762c38467d6b9" + }, + { + "path": "src/scrollable.rs", + "bytes": 80594, + "sha256": "a176ea537bbe717550fe4dc17af31c801d9ec118edbcc44aab59ea1a78d1a7a2" + }, + { + "path": "src/sensor.rs", + "bytes": 12155, + "sha256": "cb95d1220fa5663b7a148d6b159081da2200debe6fea982a04f48e27dd072973" + }, + { + "path": "src/shader/program.rs", + "bytes": 1761, + "sha256": "4ce3711b2899b1c1d53544ff0c1f823e108542d356d5e374cb7b149c776d6918" + }, + { + "path": "src/shader.rs", + "bytes": 5094, + "sha256": "8753516e6a6b422a76632d07c716e047ff9b5e8f32bf9a9463c612c292306382" + }, + { + "path": "src/slider.rs", + "bytes": 21695, + "sha256": "778d6a4c37b334cba62ada5f4958e5909a91404961fe5c1104acd9dad12a18d5" + }, + { + "path": "src/space.rs", + "bytes": 2436, + "sha256": "40839bf1dfa93e02c9e3d7e2ab5bb41b328b1fe863846f6f1feca11d9885453b" + }, + { + "path": "src/stack.rs", + "bytes": 12078, + "sha256": "b12776ae1dd46ba95ca0ca52f6a095be1e0121c51f3180ad7cc202cd55764594" + }, + { + "path": "src/svg.rs", + "bytes": 9618, + "sha256": "a8167836a19806f4c2cc633a265953b8c4348f279082d7577b69f817e3166aeb" + }, + { + "path": "src/table.rs", + "bytes": 20855, + "sha256": "373f600db363090412e6bfc9c338bd6bc9516e78f7a43fb00f20d5ed21eb625c" + }, + { + "path": "src/text/rich.rs", + "bytes": 16952, + "sha256": "7deababcbe5f0294b605822fe2651eb0fee6e9c1bee37883dbc570481df6a0b9" + }, + { + "path": "src/text.rs", + "bytes": 902, + "sha256": "a7d125fc7d07ba51e05f86ca97f4ffad5db37ef2e6d4320bbcd8475d99d51ed3" + }, + { + "path": "src/text_editor.rs", + "bytes": 48281, + "sha256": "cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1" + }, + { + "path": "src/text_input/cursor.rs", + "bytes": 5540, + "sha256": "eeb9241a25110583fd2e169da47834aeb39fd55335ea3b77aa3d1e43016c28d8" + }, + { + "path": "src/text_input/editor.rs", + "bytes": 1960, + "sha256": "cd0e0168e9de1ad2599120a4dda8a5a373813a4ffc3a3c035929d18144544408" + }, + { + "path": "src/text_input/value.rs", + "bytes": 4403, + "sha256": "03fba71d34f2c31df15431ac7e22f854b655336f86ef854ae34cc1e0bfbcefdd" + }, + { + "path": "src/text_input.rs", + "bytes": 61293, + "sha256": "7cd64b0f79c6000f2985cc290642698a814be5dcd7b7a56ae463b13ffe5c3b47" + }, + { + "path": "src/themer.rs", + "bytes": 8557, + "sha256": "4b87e866b8f2a4420f30674ad64c605d24e3004f867bf95a1210b5338ed7a81e" + }, + { + "path": "src/toggler.rs", + "bytes": 18223, + "sha256": "70f1ef0ee8e2b6c0d7b93095eab1e615783bb81d945b4ff066af025dde8f5dcb" + }, + { + "path": "src/tooltip.rs", + "bytes": 17259, + "sha256": "7fcaa3899ad1a946bb1a51d34d344497005aaccdf825f510e98105d1bc40bc63" + }, + { + "path": "src/vertical_slider.rs", + "bytes": 17741, + "sha256": "75777345ced463b4a3b059fbe6c59efc952169987154b0597d87ce4da2076e3b" + } +] diff --git a/crates/vendor/iced_widget/assets/iced-logo.svg b/crates/vendor/iced_widget/assets/iced-logo.svg new file mode 100644 index 00000000..459b7fbb --- /dev/null +++ b/crates/vendor/iced_widget/assets/iced-logo.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/vendor/iced_widget/src/action.rs b/crates/vendor/iced_widget/src/action.rs new file mode 100644 index 00000000..cc31e76a --- /dev/null +++ b/crates/vendor/iced_widget/src/action.rs @@ -0,0 +1,85 @@ +use crate::core::event; +use crate::core::time::Instant; +use crate::core::window; + +/// A runtime action that can be performed by some widgets. +#[derive(Debug, Clone)] +pub struct Action { + message_to_publish: Option, + redraw_request: window::RedrawRequest, + event_status: event::Status, +} + +impl Action { + fn new() -> Self { + Self { + message_to_publish: None, + redraw_request: window::RedrawRequest::Wait, + event_status: event::Status::Ignored, + } + } + + /// Creates a new "capturing" [`Action`]. A capturing [`Action`] + /// will make other widgets consider it final and prevent further + /// processing. + /// + /// Prevents "event bubbling". + pub fn capture() -> Self { + Self { + event_status: event::Status::Captured, + ..Self::new() + } + } + + /// Creates a new [`Action`] that publishes the given `Message` for + /// the application to handle. + /// + /// Publishing a `Message` always produces a redraw. + pub fn publish(message: Message) -> Self { + Self { + message_to_publish: Some(message), + ..Self::new() + } + } + + /// Creates a new [`Action`] that requests a redraw to happen as + /// soon as possible; without publishing any `Message`. + pub fn request_redraw() -> Self { + Self { + redraw_request: window::RedrawRequest::NextFrame, + ..Self::new() + } + } + + /// Creates a new [`Action`] that requests a redraw to happen at + /// the given [`Instant`]; without publishing any `Message`. + /// + /// This can be useful to efficiently animate content, like a + /// blinking caret on a text input. + pub fn request_redraw_at(at: Instant) -> Self { + Self { + redraw_request: window::RedrawRequest::At(at), + ..Self::new() + } + } + + /// Marks the [`Action`] as "capturing". See [`Self::capture`]. + pub fn and_capture(mut self) -> Self { + self.event_status = event::Status::Captured; + self + } + + /// Converts the [`Action`] into its internal parts. + /// + /// This method is meant to be used by runtimes, libraries, or internal + /// widget implementations. + pub fn into_inner( + self, + ) -> (Option, window::RedrawRequest, event::Status) { + ( + self.message_to_publish, + self.redraw_request, + self.event_status, + ) + } +} diff --git a/crates/vendor/iced_widget/src/button.rs b/crates/vendor/iced_widget/src/button.rs new file mode 100644 index 00000000..3dffb49f --- /dev/null +++ b/crates/vendor/iced_widget/src/button.rs @@ -0,0 +1,752 @@ +//! Buttons allow your users to perform actions by pressing them. +//! +//! # Example +//! ```no_run +//! # mod iced { pub mod widget { pub use iced_widget::*; } } +//! # pub type State = (); +//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +//! use iced::widget::button; +//! +//! #[derive(Clone)] +//! enum Message { +//! ButtonPressed, +//! } +//! +//! fn view(state: &State) -> Element<'_, Message> { +//! button("Press me!").on_press(Message::ButtonPressed).into() +//! } +//! ``` +use crate::core::border::{self, Border}; +use crate::core::layout; +use crate::core::mouse; +use crate::core::overlay; +use crate::core::renderer; +use crate::core::theme::palette; +use crate::core::touch; +use crate::core::widget::Operation; +use crate::core::widget::tree::{self, Tree}; +use crate::core::window; +use crate::core::{ + Background, Clipboard, Color, Element, Event, Layout, Length, Padding, + Rectangle, Shadow, Shell, Size, Theme, Vector, Widget, +}; + +/// A generic widget that produces a message when pressed. +/// +/// # Example +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } } +/// # pub type State = (); +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// use iced::widget::button; +/// +/// #[derive(Clone)] +/// enum Message { +/// ButtonPressed, +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// button("Press me!").on_press(Message::ButtonPressed).into() +/// } +/// ``` +/// +/// If a [`Button::on_press`] handler is not set, the resulting [`Button`] will +/// be disabled: +/// +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } } +/// # pub type State = (); +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// use iced::widget::button; +/// +/// #[derive(Clone)] +/// enum Message { +/// ButtonPressed, +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// button("I am disabled!").into() +/// } +/// ``` +pub struct Button<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> +where + Renderer: crate::core::Renderer, + Theme: Catalog, +{ + content: Element<'a, Message, Theme, Renderer>, + on_press: Option>, + width: Length, + height: Length, + padding: Padding, + clip: bool, + class: Theme::Class<'a>, + status: Option, +} + +enum OnPress<'a, Message> { + Direct(Message), + Closure(Box Message + 'a>), +} + +impl OnPress<'_, Message> { + fn get(&self) -> Message { + match self { + OnPress::Direct(message) => message.clone(), + OnPress::Closure(f) => f(), + } + } +} + +impl<'a, Message, Theme, Renderer> Button<'a, Message, Theme, Renderer> +where + Renderer: crate::core::Renderer, + Theme: Catalog, +{ + /// Creates a new [`Button`] with the given content. + pub fn new( + content: impl Into>, + ) -> Self { + let content = content.into(); + let size = content.as_widget().size_hint(); + + Button { + content, + on_press: None, + width: size.width.fluid(), + height: size.height.fluid(), + padding: DEFAULT_PADDING, + clip: false, + class: Theme::default(), + status: None, + } + } + + /// Sets the width of the [`Button`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Button`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the [`Padding`] of the [`Button`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed. + /// + /// Unless `on_press` is called, the [`Button`] will be disabled. + pub fn on_press(mut self, on_press: Message) -> Self { + self.on_press = Some(OnPress::Direct(on_press)); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed. + /// + /// This is analogous to [`Button::on_press`], but using a closure to produce + /// the message. + /// + /// This closure will only be called when the [`Button`] is actually pressed and, + /// therefore, this method is useful to reduce overhead if creating the resulting + /// message is slow. + pub fn on_press_with( + mut self, + on_press: impl Fn() -> Message + 'a, + ) -> Self { + self.on_press = Some(OnPress::Closure(Box::new(on_press))); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed, + /// if `Some`. + /// + /// If `None`, the [`Button`] will be disabled. + pub fn on_press_maybe(mut self, on_press: Option) -> Self { + self.on_press = on_press.map(OnPress::Direct); + self + } + + /// Sets whether the contents of the [`Button`] should be clipped on + /// overflow. + pub fn clip(mut self, clip: bool) -> Self { + self.clip = clip; + self + } + + /// Sets the style of the [`Button`]. + #[must_use] + pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self + where + Theme::Class<'a>: From>, + { + self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); + self + } + + /// Sets the style class of the [`Button`]. + #[cfg(feature = "advanced")] + #[must_use] + pub fn class(mut self, class: impl Into>) -> Self { + self.class = class.into(); + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct State { + is_pressed: bool, +} + +impl<'a, Message, Theme, Renderer> Widget + for Button<'a, Message, Theme, Renderer> +where + Message: 'a + Clone, + Renderer: 'a + crate::core::Renderer, + Theme: Catalog, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::padded( + limits, + self.width, + self.height, + self.padding, + |limits| { + self.content.as_widget_mut().layout( + &mut tree.children[0], + renderer, + limits, + ) + }, + ) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + operation, + ); + }); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout.children().next().unwrap(), + cursor, + renderer, + clipboard, + shell, + viewport, + ); + + if shell.is_event_captured() { + return; + } + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + if self.on_press.is_some() { + let bounds = layout.bounds(); + + if cursor.is_over(bounds) { + let state = tree.state.downcast_mut::(); + + state.is_pressed = true; + + shell.capture_event(); + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) => { + if let Some(on_press) = &self.on_press { + let state = tree.state.downcast_mut::(); + + if state.is_pressed { + state.is_pressed = false; + + let bounds = layout.bounds(); + + if cursor.is_over(bounds) { + shell.publish(on_press.get()); + } + + shell.capture_event(); + } + } + } + Event::Touch(touch::Event::FingerLost { .. }) => { + let state = tree.state.downcast_mut::(); + + state.is_pressed = false; + } + _ => {} + } + + let current_status = if self.on_press.is_none() { + Status::Disabled + } else if cursor.is_over(layout.bounds()) { + let state = tree.state.downcast_ref::(); + + if state.is_pressed { + Status::Pressed + } else { + Status::Hovered + } + } else { + Status::Active + }; + + if let Event::Window(window::Event::RedrawRequested(_now)) = event { + self.status = Some(current_status); + } else if self.status.is_some_and(|status| status != current_status) { + shell.request_redraw(); + } + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + let content_layout = layout.children().next().unwrap(); + let style = + theme.style(&self.class, self.status.unwrap_or(Status::Disabled)); + + if style.background.is_some() + || style.border.width > 0.0 + || style.shadow.color.a > 0.0 + { + renderer.fill_quad( + renderer::Quad { + bounds, + border: style.border, + shadow: style.shadow, + snap: style.snap, + }, + style + .background + .unwrap_or(Background::Color(Color::TRANSPARENT)), + ); + } + + let viewport = if self.clip { + bounds.intersection(viewport).unwrap_or(*viewport) + } else { + *viewport + }; + + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + &renderer::Style { + text_color: style.text_color, + }, + content_layout, + cursor, + &viewport, + ); + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let is_mouse_over = cursor.is_over(layout.bounds()); + + if is_mouse_over && self.on_press.is_some() { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + viewport, + translation, + ) + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'a, + Theme: Catalog + 'a, + Renderer: crate::core::Renderer + 'a, +{ + fn from(button: Button<'a, Message, Theme, Renderer>) -> Self { + Self::new(button) + } +} + +/// The default [`Padding`] of a [`Button`]. +pub const DEFAULT_PADDING: Padding = Padding { + top: 5.0, + bottom: 5.0, + right: 10.0, + left: 10.0, +}; + +/// The possible status of a [`Button`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + /// The [`Button`] can be pressed. + Active, + /// The [`Button`] can be pressed and it is being hovered. + Hovered, + /// The [`Button`] is being pressed. + Pressed, + /// The [`Button`] cannot be pressed. + Disabled, +} + +/// The style of a button. +/// +/// If not specified with [`Button::style`] +/// the theme will provide the style. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Style { + /// The [`Background`] of the button. + pub background: Option, + /// The text [`Color`] of the button. + pub text_color: Color, + /// The [`Border`] of the button. + pub border: Border, + /// The [`Shadow`] of the button. + pub shadow: Shadow, + /// Whether the button should be snapped to the pixel grid. + pub snap: bool, +} + +impl Style { + /// Updates the [`Style`] with the given [`Background`]. + pub fn with_background(self, background: impl Into) -> Self { + Self { + background: Some(background.into()), + ..self + } + } +} + +impl Default for Style { + fn default() -> Self { + Self { + background: None, + text_color: Color::BLACK, + border: Border::default(), + shadow: Shadow::default(), + snap: cfg!(feature = "crisp"), + } + } +} + +/// The theme catalog of a [`Button`]. +/// +/// All themes that can be used with [`Button`] +/// must implement this trait. +/// +/// # Example +/// ```no_run +/// # use iced_widget::core::{Color, Background}; +/// # use iced_widget::button::{Catalog, Status, Style}; +/// # struct MyTheme; +/// #[derive(Debug, Default)] +/// pub enum ButtonClass { +/// #[default] +/// Primary, +/// Secondary, +/// Danger +/// } +/// +/// impl Catalog for MyTheme { +/// type Class<'a> = ButtonClass; +/// +/// fn default<'a>() -> Self::Class<'a> { +/// ButtonClass::default() +/// } +/// +/// +/// fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { +/// let mut style = Style::default(); +/// +/// match class { +/// ButtonClass::Primary => { +/// style.background = Some(Background::Color(Color::from_rgb(0.529, 0.808, 0.921))); +/// }, +/// ButtonClass::Secondary => { +/// style.background = Some(Background::Color(Color::WHITE)); +/// }, +/// ButtonClass::Danger => { +/// style.background = Some(Background::Color(Color::from_rgb(0.941, 0.502, 0.502))); +/// }, +/// } +/// +/// style +/// } +/// } +/// ``` +/// +/// Although, in order to use [`Button::style`] +/// with `MyTheme`, [`Catalog::Class`] must implement +/// `From>`. +pub trait Catalog { + /// The item class of the [`Catalog`]. + type Class<'a>; + + /// The default class produced by the [`Catalog`]. + fn default<'a>() -> Self::Class<'a>; + + /// The [`Style`] of a class with the given status. + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style; +} + +/// A styling function for a [`Button`]. +pub type StyleFn<'a, Theme> = Box Style + 'a>; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(primary) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +/// A primary button; denoting a main action. +pub fn primary(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + let base = styled(palette.primary.base); + + match status { + Status::Active | Status::Pressed => base, + Status::Hovered => Style { + background: Some(Background::Color(palette.primary.strong.color)), + ..base + }, + Status::Disabled => disabled(base), + } +} + +/// A secondary button; denoting a complementary action. +pub fn secondary(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + let base = styled(palette.secondary.base); + + match status { + Status::Active | Status::Pressed => base, + Status::Hovered => Style { + background: Some(Background::Color(palette.secondary.strong.color)), + ..base + }, + Status::Disabled => disabled(base), + } +} + +/// A success button; denoting a good outcome. +pub fn success(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + let base = styled(palette.success.base); + + match status { + Status::Active | Status::Pressed => base, + Status::Hovered => Style { + background: Some(Background::Color(palette.success.strong.color)), + ..base + }, + Status::Disabled => disabled(base), + } +} + +/// A warning button; denoting a risky action. +pub fn warning(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + let base = styled(palette.warning.base); + + match status { + Status::Active | Status::Pressed => base, + Status::Hovered => Style { + background: Some(Background::Color(palette.warning.strong.color)), + ..base + }, + Status::Disabled => disabled(base), + } +} + +/// A danger button; denoting a destructive action. +pub fn danger(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + let base = styled(palette.danger.base); + + match status { + Status::Active | Status::Pressed => base, + Status::Hovered => Style { + background: Some(Background::Color(palette.danger.strong.color)), + ..base + }, + Status::Disabled => disabled(base), + } +} + +/// A text button; useful for links. +pub fn text(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + + let base = Style { + text_color: palette.background.base.text, + ..Style::default() + }; + + match status { + Status::Active | Status::Pressed => base, + Status::Hovered => Style { + text_color: palette.background.base.text.scale_alpha(0.8), + ..base + }, + Status::Disabled => disabled(base), + } +} + +/// A button using background shades. +pub fn background(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + let base = styled(palette.background.base); + + match status { + Status::Active => base, + Status::Pressed => Style { + background: Some(Background::Color( + palette.background.strong.color, + )), + ..base + }, + Status::Hovered => Style { + background: Some(Background::Color(palette.background.weak.color)), + ..base + }, + Status::Disabled => disabled(base), + } +} + +/// A subtle button using weak background shades. +pub fn subtle(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + let base = styled(palette.background.weakest); + + match status { + Status::Active => base, + Status::Pressed => Style { + background: Some(Background::Color( + palette.background.strong.color, + )), + ..base + }, + Status::Hovered => Style { + background: Some(Background::Color( + palette.background.weaker.color, + )), + ..base + }, + Status::Disabled => disabled(base), + } +} + +fn styled(pair: palette::Pair) -> Style { + Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: border::rounded(2), + ..Style::default() + } +} + +fn disabled(style: Style) -> Style { + Style { + background: style + .background + .map(|background| background.scale_alpha(0.5)), + text_color: style.text_color.scale_alpha(0.5), + ..style + } +} diff --git a/crates/vendor/iced_widget/src/canvas.rs b/crates/vendor/iced_widget/src/canvas.rs new file mode 100644 index 00000000..b1b6ac13 --- /dev/null +++ b/crates/vendor/iced_widget/src/canvas.rs @@ -0,0 +1,327 @@ +//! Canvases can be leveraged to draw interactive 2D graphics. +//! +//! # Example: Drawing a Simple Circle +//! ```no_run +//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } +//! # pub type State = (); +//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +//! # +//! use iced::mouse; +//! use iced::widget::canvas; +//! use iced::{Color, Rectangle, Renderer, Theme}; +//! +//! // First, we define the data we need for drawing +//! #[derive(Debug)] +//! struct Circle { +//! radius: f32, +//! } +//! +//! // Then, we implement the `Program` trait +//! impl canvas::Program for Circle { +//! // No internal state +//! type State = (); +//! +//! fn draw( +//! &self, +//! _state: &(), +//! renderer: &Renderer, +//! _theme: &Theme, +//! bounds: Rectangle, +//! _cursor: mouse::Cursor +//! ) -> Vec { +//! // We prepare a new `Frame` +//! let mut frame = canvas::Frame::new(renderer, bounds.size()); +//! +//! // We create a `Path` representing a simple circle +//! let circle = canvas::Path::circle(frame.center(), self.radius); +//! +//! // And fill it with some color +//! frame.fill(&circle, Color::BLACK); +//! +//! // Then, we produce the geometry +//! vec![frame.into_geometry()] +//! } +//! } +//! +//! // Finally, we simply use our `Circle` to create the `Canvas`! +//! fn view<'a, Message: 'a>(_state: &'a State) -> Element<'a, Message> { +//! canvas(Circle { radius: 50.0 }).into() +//! } +//! ``` +mod program; + +pub use program::Program; + +pub use crate::Action; +pub use crate::core::event::Event; +pub use crate::graphics::cache::Group; +pub use crate::graphics::geometry::{ + Fill, Gradient, Image, LineCap, LineDash, LineJoin, Path, Stroke, Style, + Text, fill, gradient, path, stroke, +}; + +use crate::core::event; +use crate::core::layout::{self, Layout}; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::widget::tree::{self, Tree}; +use crate::core::window; +use crate::core::{ + Clipboard, Element, Length, Rectangle, Shell, Size, Vector, Widget, +}; +use crate::graphics::geometry; + +use std::marker::PhantomData; + +/// A simple cache that stores generated [`Geometry`] to avoid recomputation. +/// +/// A [`Cache`] will not redraw its geometry unless the dimensions of its layer +/// change or it is explicitly cleared. +pub type Cache = geometry::Cache; + +/// The geometry supported by a renderer. +pub type Geometry = + ::Geometry; + +/// The frame supported by a renderer. +pub type Frame = geometry::Frame; + +/// A widget capable of drawing 2D graphics. +/// +/// # Example: Drawing a Simple Circle +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } +/// # pub type State = (); +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// # +/// use iced::mouse; +/// use iced::widget::canvas; +/// use iced::{Color, Rectangle, Renderer, Theme}; +/// +/// // First, we define the data we need for drawing +/// #[derive(Debug)] +/// struct Circle { +/// radius: f32, +/// } +/// +/// // Then, we implement the `Program` trait +/// impl canvas::Program for Circle { +/// // No internal state +/// type State = (); +/// +/// fn draw( +/// &self, +/// _state: &(), +/// renderer: &Renderer, +/// _theme: &Theme, +/// bounds: Rectangle, +/// _cursor: mouse::Cursor +/// ) -> Vec { +/// // We prepare a new `Frame` +/// let mut frame = canvas::Frame::new(renderer, bounds.size()); +/// +/// // We create a `Path` representing a simple circle +/// let circle = canvas::Path::circle(frame.center(), self.radius); +/// +/// // And fill it with some color +/// frame.fill(&circle, Color::BLACK); +/// +/// // Then, we produce the geometry +/// vec![frame.into_geometry()] +/// } +/// } +/// +/// // Finally, we simply use our `Circle` to create the `Canvas`! +/// fn view<'a, Message: 'a>(_state: &'a State) -> Element<'a, Message> { +/// canvas(Circle { radius: 50.0 }).into() +/// } +/// ``` +#[derive(Debug)] +pub struct Canvas +where + Renderer: geometry::Renderer, + P: Program, +{ + width: Length, + height: Length, + program: P, + message_: PhantomData, + theme_: PhantomData, + renderer_: PhantomData, + last_mouse_interaction: Option, +} + +impl Canvas +where + P: Program, + Renderer: geometry::Renderer, +{ + const DEFAULT_SIZE: f32 = 100.0; + + /// Creates a new [`Canvas`]. + pub fn new(program: P) -> Self { + Canvas { + width: Length::Fixed(Self::DEFAULT_SIZE), + height: Length::Fixed(Self::DEFAULT_SIZE), + program, + message_: PhantomData, + theme_: PhantomData, + renderer_: PhantomData, + last_mouse_interaction: None, + } + } + + /// Sets the width of the [`Canvas`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Canvas`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } +} + +impl Widget + for Canvas +where + Renderer: geometry::Renderer, + P: Program, +{ + fn tag(&self) -> tree::Tag { + struct Tag(T); + tree::Tag::of::>() + } + + fn state(&self) -> tree::State { + tree::State::new(P::State::default()) + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &mut self, + _tree: &mut Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::atomic(limits, self.width, self.height) + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + + let state = tree.state.downcast_mut::(); + let is_redraw_request = matches!( + event, + Event::Window(window::Event::RedrawRequested(_now)), + ); + + if let Some(action) = self.program.update(state, event, bounds, cursor) + { + let (message, redraw_request, event_status) = action.into_inner(); + + shell.request_redraw_at(redraw_request); + + if let Some(message) = message { + shell.publish(message); + } + + if event_status == event::Status::Captured { + shell.capture_event(); + } + } + + if shell.redraw_request() != window::RedrawRequest::NextFrame { + let mouse_interaction = self + .mouse_interaction(tree, layout, cursor, viewport, renderer); + + if is_redraw_request { + self.last_mouse_interaction = Some(mouse_interaction); + } else if self.last_mouse_interaction.is_some_and( + |last_mouse_interaction| { + last_mouse_interaction != mouse_interaction + }, + ) { + shell.request_redraw(); + } + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let bounds = layout.bounds(); + let state = tree.state.downcast_ref::(); + + self.program.mouse_interaction(state, bounds, cursor) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + + if bounds.width < 1.0 || bounds.height < 1.0 { + return; + } + + let state = tree.state.downcast_ref::(); + + renderer.with_translation( + Vector::new(bounds.x, bounds.y), + |renderer| { + let layers = + self.program.draw(state, renderer, theme, bounds, cursor); + + for layer in layers { + renderer.draw_geometry(layer); + } + }, + ); + } +} + +impl<'a, P, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: 'a + geometry::Renderer, + P: 'a + Program, +{ + fn from( + canvas: Canvas, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(canvas) + } +} diff --git a/crates/vendor/iced_widget/src/canvas/program.rs b/crates/vendor/iced_widget/src/canvas/program.rs new file mode 100644 index 00000000..9644f676 --- /dev/null +++ b/crates/vendor/iced_widget/src/canvas/program.rs @@ -0,0 +1,111 @@ +use crate::Action; +use crate::canvas::mouse; +use crate::canvas::{Event, Geometry}; +use crate::core::Rectangle; +use crate::graphics::geometry; + +/// The state and logic of a [`Canvas`]. +/// +/// A [`Program`] can mutate internal state and produce messages for an +/// application. +/// +/// [`Canvas`]: crate::Canvas +pub trait Program +where + Renderer: geometry::Renderer, +{ + /// The internal state mutated by the [`Program`]. + type State: Default + 'static; + + /// Updates the [`State`](Self::State) of the [`Program`]. + /// + /// When a [`Program`] is used in a [`Canvas`], the runtime will call this + /// method for each [`Event`]. + /// + /// This method can optionally return an [`Action`] to either notify an + /// application of any meaningful interactions, capture the event, or + /// request a redraw. + /// + /// By default, this method does and returns nothing. + /// + /// [`Canvas`]: crate::Canvas + fn update( + &self, + _state: &mut Self::State, + _event: &Event, + _bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Option> { + None + } + + /// Draws the state of the [`Program`], producing a bunch of [`Geometry`]. + /// + /// [`Geometry`] can be easily generated with a [`Frame`] or stored in a + /// [`Cache`]. + /// + /// [`Geometry`]: crate::canvas::Geometry + /// [`Frame`]: crate::canvas::Frame + /// [`Cache`]: crate::canvas::Cache + fn draw( + &self, + state: &Self::State, + renderer: &Renderer, + theme: &Theme, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Vec>; + + /// Returns the current mouse interaction of the [`Program`]. + /// + /// The interaction returned will be in effect even if the cursor position + /// is out of bounds of the program's [`Canvas`]. + /// + /// [`Canvas`]: crate::Canvas + fn mouse_interaction( + &self, + _state: &Self::State, + _bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> mouse::Interaction { + mouse::Interaction::default() + } +} + +impl Program for &T +where + Renderer: geometry::Renderer, + T: Program, +{ + type State = T::State; + + fn update( + &self, + state: &mut Self::State, + event: &Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + T::update(self, state, event, bounds, cursor) + } + + fn draw( + &self, + state: &Self::State, + renderer: &Renderer, + theme: &Theme, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Vec> { + T::draw(self, state, renderer, theme, bounds, cursor) + } + + fn mouse_interaction( + &self, + state: &Self::State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + T::mouse_interaction(self, state, bounds, cursor) + } +} diff --git a/crates/vendor/iced_widget/src/checkbox.rs b/crates/vendor/iced_widget/src/checkbox.rs new file mode 100644 index 00000000..6edab836 --- /dev/null +++ b/crates/vendor/iced_widget/src/checkbox.rs @@ -0,0 +1,707 @@ +//! Checkboxes can be used to let users make binary choices. +//! +//! # Example +//! ```no_run +//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } +//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +//! # +//! use iced::widget::checkbox; +//! +//! struct State { +//! is_checked: bool, +//! } +//! +//! enum Message { +//! CheckboxToggled(bool), +//! } +//! +//! fn view(state: &State) -> Element<'_, Message> { +//! checkbox(state.is_checked) +//! .label("Toggle me!") +//! .on_toggle(Message::CheckboxToggled) +//! .into() +//! } +//! +//! fn update(state: &mut State, message: Message) { +//! match message { +//! Message::CheckboxToggled(is_checked) => { +//! state.is_checked = is_checked; +//! } +//! } +//! } +//! ``` +//! ![Checkbox drawn by `iced_wgpu`](https://github.com/iced-rs/iced/blob/7760618fb112074bc40b148944521f312152012a/docs/images/checkbox.png?raw=true) +use crate::core::alignment; +use crate::core::layout; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::text; +use crate::core::theme::palette; +use crate::core::touch; +use crate::core::widget; +use crate::core::widget::tree::{self, Tree}; +use crate::core::window; +use crate::core::{ + Background, Border, Clipboard, Color, Element, Event, Layout, Length, + Pixels, Rectangle, Shell, Size, Theme, Widget, +}; + +/// A box that can be checked. +/// +/// # Example +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// # +/// use iced::widget::checkbox; +/// +/// struct State { +/// is_checked: bool, +/// } +/// +/// enum Message { +/// CheckboxToggled(bool), +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// checkbox(state.is_checked) +/// .label("Toggle me!") +/// .on_toggle(Message::CheckboxToggled) +/// .into() +/// } +/// +/// fn update(state: &mut State, message: Message) { +/// match message { +/// Message::CheckboxToggled(is_checked) => { +/// state.is_checked = is_checked; +/// } +/// } +/// } +/// ``` +/// ![Checkbox drawn by `iced_wgpu`](https://github.com/iced-rs/iced/blob/7760618fb112074bc40b148944521f312152012a/docs/images/checkbox.png?raw=true) +pub struct Checkbox< + 'a, + Message, + Theme = crate::Theme, + Renderer = crate::Renderer, +> where + Renderer: text::Renderer, + Theme: Catalog, +{ + is_checked: bool, + on_toggle: Option Message + 'a>>, + label: Option>, + width: Length, + size: f32, + spacing: f32, + text_size: Option, + text_line_height: text::LineHeight, + text_shaping: text::Shaping, + text_wrapping: text::Wrapping, + font: Option, + icon: Icon, + class: Theme::Class<'a>, + last_status: Option, +} + +impl<'a, Message, Theme, Renderer> Checkbox<'a, Message, Theme, Renderer> +where + Renderer: text::Renderer, + Theme: Catalog, +{ + /// The default size of a [`Checkbox`]. + const DEFAULT_SIZE: f32 = 16.0; + + /// Creates a new [`Checkbox`]. + /// + /// It expects: + /// * a boolean describing whether the [`Checkbox`] is checked or not + pub fn new(is_checked: bool) -> Self { + Checkbox { + is_checked, + on_toggle: None, + label: None, + width: Length::Shrink, + size: Self::DEFAULT_SIZE, + spacing: Self::DEFAULT_SIZE / 2.0, + text_size: None, + text_line_height: text::LineHeight::default(), + text_shaping: text::Shaping::default(), + text_wrapping: text::Wrapping::default(), + font: None, + icon: Icon { + font: Renderer::ICON_FONT, + code_point: Renderer::CHECKMARK_ICON, + size: None, + line_height: text::LineHeight::default(), + shaping: text::Shaping::Basic, + }, + class: Theme::default(), + last_status: None, + } + } + + /// Sets the label of the [`Checkbox`]. + pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self { + self.label = Some(label.into_fragment()); + self + } + + /// Sets the function that will be called when the [`Checkbox`] is toggled. + /// It will receive the new state of the [`Checkbox`] and must produce a + /// `Message`. + /// + /// Unless `on_toggle` is called, the [`Checkbox`] will be disabled. + pub fn on_toggle(mut self, f: F) -> Self + where + F: 'a + Fn(bool) -> Message, + { + self.on_toggle = Some(Box::new(f)); + self + } + + /// Sets the function that will be called when the [`Checkbox`] is toggled, + /// if `Some`. + /// + /// If `None`, the checkbox will be disabled. + pub fn on_toggle_maybe(mut self, f: Option) -> Self + where + F: Fn(bool) -> Message + 'a, + { + self.on_toggle = f.map(|f| Box::new(f) as _); + self + } + + /// Sets the size of the [`Checkbox`]. + pub fn size(mut self, size: impl Into) -> Self { + self.size = size.into().0; + self + } + + /// Sets the width of the [`Checkbox`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the spacing between the [`Checkbox`] and the text. + pub fn spacing(mut self, spacing: impl Into) -> Self { + self.spacing = spacing.into().0; + self + } + + /// Sets the text size of the [`Checkbox`]. + pub fn text_size(mut self, text_size: impl Into) -> Self { + self.text_size = Some(text_size.into()); + self + } + + /// Sets the text [`text::LineHeight`] of the [`Checkbox`]. + pub fn text_line_height( + mut self, + line_height: impl Into, + ) -> Self { + self.text_line_height = line_height.into(); + self + } + + /// Sets the [`text::Shaping`] strategy of the [`Checkbox`]. + pub fn text_shaping(mut self, shaping: text::Shaping) -> Self { + self.text_shaping = shaping; + self + } + + /// Sets the [`text::Wrapping`] strategy of the [`Checkbox`]. + pub fn text_wrapping(mut self, wrapping: text::Wrapping) -> Self { + self.text_wrapping = wrapping; + self + } + + /// Sets the [`Renderer::Font`] of the text of the [`Checkbox`]. + /// + /// [`Renderer::Font`]: crate::core::text::Renderer + pub fn font(mut self, font: impl Into) -> Self { + self.font = Some(font.into()); + self + } + + /// Sets the [`Icon`] of the [`Checkbox`]. + pub fn icon(mut self, icon: Icon) -> Self { + self.icon = icon; + self + } + + /// Sets the style of the [`Checkbox`]. + #[must_use] + pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self + where + Theme::Class<'a>: From>, + { + self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); + self + } + + /// Sets the style class of the [`Checkbox`]. + #[cfg(feature = "advanced")] + #[must_use] + pub fn class(mut self, class: impl Into>) -> Self { + self.class = class.into(); + self + } +} + +impl Widget + for Checkbox<'_, Message, Theme, Renderer> +where + Renderer: text::Renderer, + Theme: Catalog, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::>() + } + + fn state(&self) -> tree::State { + tree::State::new(widget::text::State::::default()) + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: Length::Shrink, + } + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::next_to_each_other( + &limits.width(self.width), + if self.label.is_some() { + self.spacing + } else { + 0.0 + }, + |_| layout::Node::new(Size::new(self.size, self.size)), + |limits| { + if let Some(label) = self.label.as_deref() { + let state = tree + .state + .downcast_mut::>(); + + widget::text::layout( + state, + renderer, + limits, + label, + widget::text::Format { + width: self.width, + height: Length::Shrink, + line_height: self.text_line_height, + size: self.text_size, + font: self.font, + align_x: text::Alignment::Default, + align_y: alignment::Vertical::Top, + shaping: self.text_shaping, + wrapping: self.text_wrapping, + }, + ) + } else { + layout::Node::new(Size::ZERO) + } + }, + ) + } + + fn update( + &mut self, + _tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) { + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + let mouse_over = cursor.is_over(layout.bounds()); + + if mouse_over && let Some(on_toggle) = &self.on_toggle { + shell.publish((on_toggle)(!self.is_checked)); + shell.capture_event(); + } + } + _ => {} + } + + let current_status = { + let is_mouse_over = cursor.is_over(layout.bounds()); + let is_disabled = self.on_toggle.is_none(); + let is_checked = self.is_checked; + + if is_disabled { + Status::Disabled { is_checked } + } else if is_mouse_over { + Status::Hovered { is_checked } + } else { + Status::Active { is_checked } + } + }; + + if let Event::Window(window::Event::RedrawRequested(_now)) = event { + self.last_status = Some(current_status); + } else if self + .last_status + .is_some_and(|status| status != current_status) + { + shell.request_redraw(); + } + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + if cursor.is_over(layout.bounds()) && self.on_toggle.is_some() { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + defaults: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let mut children = layout.children(); + + let style = theme.style( + &self.class, + self.last_status.unwrap_or(Status::Disabled { + is_checked: self.is_checked, + }), + ); + + { + let layout = children.next().unwrap(); + let bounds = layout.bounds(); + + renderer.fill_quad( + renderer::Quad { + bounds, + border: style.border, + ..renderer::Quad::default() + }, + style.background, + ); + + let Icon { + font, + code_point, + size, + line_height, + shaping, + } = &self.icon; + let size = size.unwrap_or(Pixels(bounds.height * 0.7)); + + if self.is_checked { + renderer.fill_text( + text::Text { + content: code_point.to_string(), + font: *font, + size, + line_height: *line_height, + bounds: bounds.size(), + align_x: text::Alignment::Center, + align_y: alignment::Vertical::Center, + shaping: *shaping, + wrapping: text::Wrapping::default(), + }, + bounds.center(), + style.icon_color, + *viewport, + ); + } + } + + if self.label.is_none() { + return; + } + + { + let label_layout = children.next().unwrap(); + let state: &widget::text::State = + tree.state.downcast_ref(); + + crate::text::draw( + renderer, + defaults, + label_layout.bounds(), + state.raw(), + crate::text::Style { + color: style.text_color, + }, + viewport, + ); + } + } + + fn operate( + &mut self, + _tree: &mut Tree, + layout: Layout<'_>, + _renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + if let Some(label) = self.label.as_deref() { + operation.text(None, layout.bounds(), label); + } + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a + Catalog, + Renderer: 'a + text::Renderer, +{ + fn from( + checkbox: Checkbox<'a, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(checkbox) + } +} + +/// The icon in a [`Checkbox`]. +#[derive(Debug, Clone, PartialEq)] +pub struct Icon { + /// Font that will be used to display the `code_point`, + pub font: Font, + /// The unicode code point that will be used as the icon. + pub code_point: char, + /// Font size of the content. + pub size: Option, + /// The line height of the icon. + pub line_height: text::LineHeight, + /// The shaping strategy of the icon. + pub shaping: text::Shaping, +} + +/// The possible status of a [`Checkbox`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + /// The [`Checkbox`] can be interacted with. + Active { + /// Indicates if the [`Checkbox`] is currently checked. + is_checked: bool, + }, + /// The [`Checkbox`] can be interacted with and it is being hovered. + Hovered { + /// Indicates if the [`Checkbox`] is currently checked. + is_checked: bool, + }, + /// The [`Checkbox`] cannot be interacted with. + Disabled { + /// Indicates if the [`Checkbox`] is currently checked. + is_checked: bool, + }, +} + +/// The style of a checkbox. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Style { + /// The [`Background`] of the checkbox. + pub background: Background, + /// The icon [`Color`] of the checkbox. + pub icon_color: Color, + /// The [`Border`] of the checkbox. + pub border: Border, + /// The text [`Color`] of the checkbox. + pub text_color: Option, +} + +/// The theme catalog of a [`Checkbox`]. +pub trait Catalog: Sized { + /// The item class of the [`Catalog`]. + type Class<'a>; + + /// The default class produced by the [`Catalog`]. + fn default<'a>() -> Self::Class<'a>; + + /// The [`Style`] of a class with the given status. + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style; +} + +/// A styling function for a [`Checkbox`]. +/// +/// This is just a boxed closure: `Fn(&Theme, Status) -> Style`. +pub type StyleFn<'a, Theme> = Box Style + 'a>; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(primary) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +/// A primary checkbox; denoting a main toggle. +pub fn primary(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + + match status { + Status::Active { is_checked } => styled( + palette.background.strong.color, + palette.background.base, + palette.primary.base.text, + palette.primary.base, + is_checked, + ), + Status::Hovered { is_checked } => styled( + palette.background.strong.color, + palette.background.weak, + palette.primary.base.text, + palette.primary.strong, + is_checked, + ), + Status::Disabled { is_checked } => styled( + palette.background.weak.color, + palette.background.weaker, + palette.primary.base.text, + palette.background.strong, + is_checked, + ), + } +} + +/// A secondary checkbox; denoting a complementary toggle. +pub fn secondary(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + + match status { + Status::Active { is_checked } => styled( + palette.background.strong.color, + palette.background.base, + palette.background.base.text, + palette.background.strong, + is_checked, + ), + Status::Hovered { is_checked } => styled( + palette.background.strong.color, + palette.background.weak, + palette.background.base.text, + palette.background.strong, + is_checked, + ), + Status::Disabled { is_checked } => styled( + palette.background.weak.color, + palette.background.weak, + palette.background.base.text, + palette.background.weak, + is_checked, + ), + } +} + +/// A success checkbox; denoting a positive toggle. +pub fn success(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + + match status { + Status::Active { is_checked } => styled( + palette.background.weak.color, + palette.background.base, + palette.success.base.text, + palette.success.base, + is_checked, + ), + Status::Hovered { is_checked } => styled( + palette.background.strong.color, + palette.background.weak, + palette.success.base.text, + palette.success.strong, + is_checked, + ), + Status::Disabled { is_checked } => styled( + palette.background.weak.color, + palette.background.weak, + palette.success.base.text, + palette.success.weak, + is_checked, + ), + } +} + +/// A danger checkbox; denoting a negative toggle. +pub fn danger(theme: &Theme, status: Status) -> Style { + let palette = theme.extended_palette(); + + match status { + Status::Active { is_checked } => styled( + palette.background.strong.color, + palette.background.base, + palette.danger.base.text, + palette.danger.base, + is_checked, + ), + Status::Hovered { is_checked } => styled( + palette.background.strong.color, + palette.background.weak, + palette.danger.base.text, + palette.danger.strong, + is_checked, + ), + Status::Disabled { is_checked } => styled( + palette.background.weak.color, + palette.background.weak, + palette.danger.base.text, + palette.danger.weak, + is_checked, + ), + } +} + +fn styled( + border_color: Color, + base: palette::Pair, + icon_color: Color, + accent: palette::Pair, + is_checked: bool, +) -> Style { + let (background, border) = if is_checked { + (accent, accent.color) + } else { + (base, border_color) + }; + + Style { + background: Background::Color(background.color), + icon_color, + border: Border { + radius: 2.0.into(), + width: 1.0, + color: border, + }, + text_color: None, + } +} diff --git a/crates/vendor/iced_widget/src/column.rs b/crates/vendor/iced_widget/src/column.rs new file mode 100644 index 00000000..0db0c74c --- /dev/null +++ b/crates/vendor/iced_widget/src/column.rs @@ -0,0 +1,617 @@ +//! Distribute content vertically. +use crate::core::alignment::{self, Alignment}; +use crate::core::layout; +use crate::core::mouse; +use crate::core::overlay; +use crate::core::renderer; +use crate::core::widget::{Operation, Tree}; +use crate::core::{ + Clipboard, Element, Event, Layout, Length, Padding, Pixels, Rectangle, + Shell, Size, Vector, Widget, +}; + +/// A container that distributes its contents vertically. +/// +/// # Example +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } } +/// # pub type State = (); +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// use iced::widget::{button, column}; +/// +/// #[derive(Debug, Clone)] +/// enum Message { +/// // ... +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// column![ +/// "I am on top!", +/// button("I am in the center!"), +/// "I am below.", +/// ].into() +/// } +/// ``` +pub struct Column<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> +{ + spacing: f32, + padding: Padding, + width: Length, + height: Length, + max_width: f32, + align: Alignment, + clip: bool, + children: Vec>, +} + +impl<'a, Message, Theme, Renderer> Column<'a, Message, Theme, Renderer> +where + Renderer: crate::core::Renderer, +{ + /// Creates an empty [`Column`]. + pub fn new() -> Self { + Self::from_vec(Vec::new()) + } + + /// Creates a [`Column`] with the given capacity. + pub fn with_capacity(capacity: usize) -> Self { + Self::from_vec(Vec::with_capacity(capacity)) + } + + /// Creates a [`Column`] with the given elements. + pub fn with_children( + children: impl IntoIterator>, + ) -> Self { + let iterator = children.into_iter(); + + Self::with_capacity(iterator.size_hint().0).extend(iterator) + } + + /// Creates a [`Column`] from an already allocated [`Vec`]. + /// + /// Keep in mind that the [`Column`] will not inspect the [`Vec`], which means + /// it won't automatically adapt to the sizing strategy of its contents. + /// + /// If any of the children have a [`Length::Fill`] strategy, you will need to + /// call [`Column::width`] or [`Column::height`] accordingly. + pub fn from_vec( + children: Vec>, + ) -> Self { + Self { + spacing: 0.0, + padding: Padding::ZERO, + width: Length::Shrink, + height: Length::Shrink, + max_width: f32::INFINITY, + align: Alignment::Start, + clip: false, + children, + } + } + + /// Sets the vertical spacing _between_ elements. + /// + /// Custom margins per element do not exist in iced. You should use this + /// method instead! While less flexible, it helps you keep spacing between + /// elements consistent. + pub fn spacing(mut self, amount: impl Into) -> Self { + self.spacing = amount.into().0; + self + } + + /// Sets the [`Padding`] of the [`Column`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the width of the [`Column`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Column`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the maximum width of the [`Column`]. + pub fn max_width(mut self, max_width: impl Into) -> Self { + self.max_width = max_width.into().0; + self + } + + /// Sets the horizontal alignment of the contents of the [`Column`] . + pub fn align_x(mut self, align: impl Into) -> Self { + self.align = Alignment::from(align.into()); + self + } + + /// Sets whether the contents of the [`Column`] should be clipped on + /// overflow. + pub fn clip(mut self, clip: bool) -> Self { + self.clip = clip; + self + } + + /// Adds an element to the [`Column`]. + pub fn push( + mut self, + child: impl Into>, + ) -> Self { + let child = child.into(); + let child_size = child.as_widget().size_hint(); + + if !child_size.is_void() { + self.width = self.width.enclose(child_size.width); + self.height = self.height.enclose(child_size.height); + self.children.push(child); + } + + self + } + + /// Extends the [`Column`] with the given children. + pub fn extend( + self, + children: impl IntoIterator>, + ) -> Self { + children.into_iter().fold(self, Self::push) + } + + /// Turns the [`Column`] into a [`Wrapping`] column. + /// + /// The original alignment of the [`Column`] is preserved per column wrapped. + pub fn wrap(self) -> Wrapping<'a, Message, Theme, Renderer> { + Wrapping { + column: self, + horizontal_spacing: None, + align_y: alignment::Vertical::Top, + } + } +} + +impl Default for Column<'_, Message, Renderer> +where + Renderer: crate::core::Renderer, +{ + fn default() -> Self { + Self::new() + } +} + +impl<'a, Message, Theme, Renderer: crate::core::Renderer> + FromIterator> + for Column<'a, Message, Theme, Renderer> +{ + fn from_iter< + T: IntoIterator>, + >( + iter: T, + ) -> Self { + Self::with_children(iter) + } +} + +impl Widget + for Column<'_, Message, Theme, Renderer> +where + Renderer: crate::core::Renderer, +{ + fn children(&self) -> Vec { + self.children.iter().map(Tree::new).collect() + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(&self.children); + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let limits = limits.max_width(self.max_width); + + layout::flex::resolve( + layout::flex::Axis::Vertical, + renderer, + &limits, + self.width, + self.height, + self.padding, + self.spacing, + self.align, + &mut self.children, + &mut tree.children, + ) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { + self.children + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + .for_each(|((child, state), layout)| { + child + .as_widget_mut() + .operate(state, layout, renderer, operation); + }); + }); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + for ((child, tree), layout) in self + .children + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + { + child.as_widget_mut().update( + tree, event, layout, cursor, renderer, clipboard, shell, + viewport, + ); + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .map(|((child, tree), layout)| { + child + .as_widget() + .mouse_interaction(tree, layout, cursor, viewport, renderer) + }) + .max() + .unwrap_or_default() + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + if let Some(clipped_viewport) = layout.bounds().intersection(viewport) { + let viewport = if self.clip { + &clipped_viewport + } else { + viewport + }; + + for ((child, tree), layout) in self + .children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .filter(|(_, layout)| layout.bounds().intersects(viewport)) + { + child.as_widget().draw( + tree, renderer, theme, style, layout, cursor, viewport, + ); + } + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + overlay::from_children( + &mut self.children, + tree, + layout, + renderer, + viewport, + translation, + ) + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: crate::core::Renderer + 'a, +{ + fn from(column: Column<'a, Message, Theme, Renderer>) -> Self { + Self::new(column) + } +} + +/// A [`Column`] that wraps its contents. +/// +/// Create a [`Column`] first, and then call [`Column::wrap`] to +/// obtain a [`Column`] that wraps its contents. +/// +/// The original alignment of the [`Column`] is preserved per column wrapped. +#[allow(missing_debug_implementations)] +pub struct Wrapping< + 'a, + Message, + Theme = crate::Theme, + Renderer = crate::Renderer, +> { + column: Column<'a, Message, Theme, Renderer>, + horizontal_spacing: Option, + align_y: alignment::Vertical, +} + +impl Wrapping<'_, Message, Theme, Renderer> { + /// Sets the horizontal spacing _between_ columns. + pub fn horizontal_spacing(mut self, amount: impl Into) -> Self { + self.horizontal_spacing = Some(amount.into().0); + self + } + + /// Sets the vertical alignment of the wrapping [`Column`]. + pub fn align_x(mut self, align_y: impl Into) -> Self { + self.align_y = align_y.into(); + self + } +} + +impl Widget + for Wrapping<'_, Message, Theme, Renderer> +where + Renderer: crate::core::Renderer, +{ + fn children(&self) -> Vec { + self.column.children() + } + + fn diff(&self, tree: &mut Tree) { + self.column.diff(tree); + } + + fn size(&self) -> Size { + self.column.size() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let limits = limits + .width(self.column.width) + .height(self.column.height) + .shrink(self.column.padding); + + let child_limits = limits.loose(); + let spacing = self.column.spacing; + let horizontal_spacing = self.horizontal_spacing.unwrap_or(spacing); + let max_height = limits.max().height; + + let mut children: Vec = Vec::new(); + let mut intrinsic_size = Size::ZERO; + let mut column_start = 0; + let mut column_width = 0.0; + let mut x = 0.0; + let mut y = 0.0; + + let align_factor = match self.column.align { + Alignment::Start => 0.0, + Alignment::Center => 2.0, + Alignment::End => 1.0, + }; + + let align_x = |column_start: std::ops::Range, + column_width: f32, + children: &mut Vec| { + if align_factor != 0.0 { + for node in &mut children[column_start] { + let width = node.size().width; + + node.translate_mut(Vector::new( + (column_width - width) / align_factor, + 0.0, + )); + } + } + }; + + for (i, child) in self.column.children.iter_mut().enumerate() { + let node = child.as_widget_mut().layout( + &mut tree.children[i], + renderer, + &child_limits, + ); + + let child_size = node.size(); + + if y != 0.0 && y + child_size.height > max_height { + intrinsic_size.height = intrinsic_size.height.max(y - spacing); + + align_x(column_start..i, column_width, &mut children); + + x += column_width + horizontal_spacing; + y = 0.0; + column_start = i; + column_width = 0.0; + } + + column_width = column_width.max(child_size.width); + + children.push(node.move_to(( + x + self.column.padding.left, + y + self.column.padding.top, + ))); + + y += child_size.height + spacing; + } + + if y != 0.0 { + intrinsic_size.height = intrinsic_size.height.max(y - spacing); + } + + intrinsic_size.width = x + column_width; + align_x(column_start..children.len(), column_width, &mut children); + + let align_factor = match self.align_y { + alignment::Vertical::Top => 0.0, + alignment::Vertical::Center => 2.0, + alignment::Vertical::Bottom => 1.0, + }; + + if align_factor != 0.0 { + let total_height = intrinsic_size.height; + + let mut column_start = 0; + + for i in 0..children.len() { + let bounds = children[i].bounds(); + let column_height = bounds.y + bounds.height; + + let next_y = children + .get(i + 1) + .map(|node| node.bounds().y) + .unwrap_or_default(); + + if next_y == 0.0 { + let translation = Vector::new( + 0.0, + (total_height - column_height) / align_factor, + ); + + for node in &mut children[column_start..=i] { + node.translate_mut(translation); + } + + column_start = i + 1; + } + } + } + + let size = limits.resolve( + self.column.width, + self.column.height, + intrinsic_size, + ); + + layout::Node::with_children(size.expand(self.column.padding), children) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + self.column.operate(tree, layout, renderer, operation); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.column.update( + tree, event, layout, cursor, renderer, clipboard, shell, viewport, + ); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.column + .mouse_interaction(tree, layout, cursor, viewport, renderer) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.column + .draw(tree, renderer, theme, style, layout, cursor, viewport); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.column + .overlay(tree, layout, renderer, viewport, translation) + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: crate::core::Renderer + 'a, +{ + fn from(column: Wrapping<'a, Message, Theme, Renderer>) -> Self { + Self::new(column) + } +} diff --git a/crates/vendor/iced_widget/src/combo_box.rs b/crates/vendor/iced_widget/src/combo_box.rs new file mode 100644 index 00000000..775702cd --- /dev/null +++ b/crates/vendor/iced_widget/src/combo_box.rs @@ -0,0 +1,1013 @@ +//! Combo boxes display a dropdown list of searchable and selectable options. +//! +//! # Example +//! ```no_run +//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } +//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +//! # +//! use iced::widget::combo_box; +//! +//! struct State { +//! fruits: combo_box::State, +//! favorite: Option, +//! } +//! +//! #[derive(Debug, Clone)] +//! enum Fruit { +//! Apple, +//! Orange, +//! Strawberry, +//! Tomato, +//! } +//! +//! #[derive(Debug, Clone)] +//! enum Message { +//! FruitSelected(Fruit), +//! } +//! +//! fn view(state: &State) -> Element<'_, Message> { +//! combo_box( +//! &state.fruits, +//! "Select your favorite fruit...", +//! state.favorite.as_ref(), +//! Message::FruitSelected +//! ) +//! .into() +//! } +//! +//! fn update(state: &mut State, message: Message) { +//! match message { +//! Message::FruitSelected(fruit) => { +//! state.favorite = Some(fruit); +//! } +//! } +//! } +//! +//! impl std::fmt::Display for Fruit { +//! fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +//! f.write_str(match self { +//! Self::Apple => "Apple", +//! Self::Orange => "Orange", +//! Self::Strawberry => "Strawberry", +//! Self::Tomato => "Tomato", +//! }) +//! } +//! } +//! ``` +use crate::core::keyboard; +use crate::core::keyboard::key; +use crate::core::layout::{self, Layout}; +use crate::core::mouse; +use crate::core::overlay; +use crate::core::renderer; +use crate::core::text; +use crate::core::time::Instant; +use crate::core::widget::{self, Widget}; +use crate::core::{ + Clipboard, Element, Event, Length, Padding, Pixels, Rectangle, Shell, Size, + Theme, Vector, +}; +use crate::overlay::menu; +use crate::text::LineHeight; +use crate::text_input::{self, TextInput}; + +use std::cell::RefCell; +use std::fmt::Display; + +/// A widget for searching and selecting a single value from a list of options. +/// +/// # Example +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// # +/// use iced::widget::combo_box; +/// +/// struct State { +/// fruits: combo_box::State, +/// favorite: Option, +/// } +/// +/// #[derive(Debug, Clone)] +/// enum Fruit { +/// Apple, +/// Orange, +/// Strawberry, +/// Tomato, +/// } +/// +/// #[derive(Debug, Clone)] +/// enum Message { +/// FruitSelected(Fruit), +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// combo_box( +/// &state.fruits, +/// "Select your favorite fruit...", +/// state.favorite.as_ref(), +/// Message::FruitSelected +/// ) +/// .into() +/// } +/// +/// fn update(state: &mut State, message: Message) { +/// match message { +/// Message::FruitSelected(fruit) => { +/// state.favorite = Some(fruit); +/// } +/// } +/// } +/// +/// impl std::fmt::Display for Fruit { +/// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +/// f.write_str(match self { +/// Self::Apple => "Apple", +/// Self::Orange => "Orange", +/// Self::Strawberry => "Strawberry", +/// Self::Tomato => "Tomato", +/// }) +/// } +/// } +/// ``` +pub struct ComboBox< + 'a, + T, + Message, + Theme = crate::Theme, + Renderer = crate::Renderer, +> where + Theme: Catalog, + Renderer: text::Renderer, +{ + state: &'a State, + text_input: TextInput<'a, TextInputEvent, Theme, Renderer>, + font: Option, + selection: text_input::Value, + on_selected: Box Message>, + on_option_hovered: Option Message>>, + on_open: Option, + on_close: Option, + on_input: Option Message>>, + padding: Padding, + size: Option, + text_shaping: text::Shaping, + menu_class: ::Class<'a>, + menu_height: Length, +} + +impl<'a, T, Message, Theme, Renderer> ComboBox<'a, T, Message, Theme, Renderer> +where + T: std::fmt::Display + Clone, + Theme: Catalog, + Renderer: text::Renderer, +{ + /// Creates a new [`ComboBox`] with the given list of options, a placeholder, + /// the current selected value, and the message to produce when an option is + /// selected. + pub fn new( + state: &'a State, + placeholder: &str, + selection: Option<&T>, + on_selected: impl Fn(T) -> Message + 'static, + ) -> Self { + let text_input = TextInput::new(placeholder, &state.value()) + .on_input(TextInputEvent::TextChanged) + .class(Theme::default_input()); + + let selection = selection.map(T::to_string).unwrap_or_default(); + + Self { + state, + text_input, + font: None, + selection: text_input::Value::new(&selection), + on_selected: Box::new(on_selected), + on_option_hovered: None, + on_input: None, + on_open: None, + on_close: None, + padding: text_input::DEFAULT_PADDING, + size: None, + text_shaping: text::Shaping::default(), + menu_class: ::default_menu(), + menu_height: Length::Shrink, + } + } + + /// Sets the message that should be produced when some text is typed into + /// the [`TextInput`] of the [`ComboBox`]. + pub fn on_input( + mut self, + on_input: impl Fn(String) -> Message + 'static, + ) -> Self { + self.on_input = Some(Box::new(on_input)); + self + } + + /// Sets the message that will be produced when an option of the + /// [`ComboBox`] is hovered using the arrow keys. + pub fn on_option_hovered( + mut self, + on_option_hovered: impl Fn(T) -> Message + 'static, + ) -> Self { + self.on_option_hovered = Some(Box::new(on_option_hovered)); + self + } + + /// Sets the message that will be produced when the [`ComboBox`] is + /// opened. + pub fn on_open(mut self, message: Message) -> Self { + self.on_open = Some(message); + self + } + + /// Sets the message that will be produced when the outside area + /// of the [`ComboBox`] is pressed. + pub fn on_close(mut self, message: Message) -> Self { + self.on_close = Some(message); + self + } + + /// Sets the [`Padding`] of the [`ComboBox`]. + pub fn padding(mut self, padding: impl Into) -> Self { + self.padding = padding.into(); + self.text_input = self.text_input.padding(self.padding); + self + } + + /// Sets the [`Renderer::Font`] of the [`ComboBox`]. + /// + /// [`Renderer::Font`]: text::Renderer + pub fn font(mut self, font: Renderer::Font) -> Self { + self.text_input = self.text_input.font(font); + self.font = Some(font); + self + } + + /// Sets the [`text_input::Icon`] of the [`ComboBox`]. + pub fn icon(mut self, icon: text_input::Icon) -> Self { + self.text_input = self.text_input.icon(icon); + self + } + + /// Sets the text sixe of the [`ComboBox`]. + pub fn size(mut self, size: impl Into) -> Self { + let size = size.into(); + + self.text_input = self.text_input.size(size); + self.size = Some(size.0); + + self + } + + /// Sets the [`LineHeight`] of the [`ComboBox`]. + pub fn line_height(self, line_height: impl Into) -> Self { + Self { + text_input: self.text_input.line_height(line_height), + ..self + } + } + + /// Sets the width of the [`ComboBox`]. + pub fn width(self, width: impl Into) -> Self { + Self { + text_input: self.text_input.width(width), + ..self + } + } + + /// Sets the height of the menu of the [`ComboBox`]. + pub fn menu_height(mut self, menu_height: impl Into) -> Self { + self.menu_height = menu_height.into(); + self + } + + /// Sets the [`text::Shaping`] strategy of the [`ComboBox`]. + pub fn text_shaping(mut self, shaping: text::Shaping) -> Self { + self.text_shaping = shaping; + self + } + + /// Sets the style of the input of the [`ComboBox`]. + #[must_use] + pub fn input_style( + mut self, + style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a, + ) -> Self + where + ::Class<'a>: + From>, + { + self.text_input = self.text_input.style(style); + self + } + + /// Sets the style of the menu of the [`ComboBox`]. + #[must_use] + pub fn menu_style( + mut self, + style: impl Fn(&Theme) -> menu::Style + 'a, + ) -> Self + where + ::Class<'a>: From>, + { + self.menu_class = (Box::new(style) as menu::StyleFn<'a, Theme>).into(); + self + } + + /// Sets the style class of the input of the [`ComboBox`]. + #[cfg(feature = "advanced")] + #[must_use] + pub fn input_class( + mut self, + class: impl Into<::Class<'a>>, + ) -> Self { + self.text_input = self.text_input.class(class); + self + } + + /// Sets the style class of the menu of the [`ComboBox`]. + #[cfg(feature = "advanced")] + #[must_use] + pub fn menu_class( + mut self, + class: impl Into<::Class<'a>>, + ) -> Self { + self.menu_class = class.into(); + self + } +} + +/// The local state of a [`ComboBox`]. +#[derive(Debug, Clone)] +pub struct State { + options: Vec, + inner: RefCell>, +} + +#[derive(Debug, Clone)] +struct Inner { + value: String, + option_matchers: Vec, + filtered_options: Filtered, +} + +#[derive(Debug, Clone)] +struct Filtered { + options: Vec, + updated: Instant, +} + +impl State +where + T: Display + Clone, +{ + /// Creates a new [`State`] for a [`ComboBox`] with the given list of options. + pub fn new(options: Vec) -> Self { + Self::with_selection(options, None) + } + + /// Creates a new [`State`] for a [`ComboBox`] with the given list of options + /// and selected value. + pub fn with_selection(options: Vec, selection: Option<&T>) -> Self { + let value = selection.map(T::to_string).unwrap_or_default(); + + // Pre-build "matcher" strings ahead of time so that search is fast + let option_matchers = build_matchers(&options); + + let filtered_options = Filtered::new( + search(&options, &option_matchers, &value) + .cloned() + .collect(), + ); + + Self { + options, + inner: RefCell::new(Inner { + value, + option_matchers, + filtered_options, + }), + } + } + + /// Returns the options of the [`State`]. + /// + /// These are the options provided when the [`State`] + /// was constructed with [`State::new`]. + pub fn options(&self) -> &[T] { + &self.options + } + + /// Pushes a new option to the [`State`]. + pub fn push(&mut self, new_option: T) { + let mut inner = self.inner.borrow_mut(); + + inner.option_matchers.push(build_matcher(&new_option)); + self.options.push(new_option); + + inner.filtered_options = Filtered::new( + search(&self.options, &inner.option_matchers, &inner.value) + .cloned() + .collect(), + ); + } + + /// Returns ownership of the options of the [`State`]. + pub fn into_options(self) -> Vec { + self.options + } + + fn value(&self) -> String { + let inner = self.inner.borrow(); + + inner.value.clone() + } + + fn with_inner(&self, f: impl FnOnce(&Inner) -> O) -> O { + let inner = self.inner.borrow(); + + f(&inner) + } + + fn with_inner_mut(&self, f: impl FnOnce(&mut Inner)) { + let mut inner = self.inner.borrow_mut(); + + f(&mut inner); + } + + fn sync_filtered_options(&self, options: &mut Filtered) { + let inner = self.inner.borrow(); + + inner.filtered_options.sync(options); + } +} + +impl Default for State +where + T: Display + Clone, +{ + fn default() -> Self { + Self::new(Vec::new()) + } +} + +impl Filtered +where + T: Clone, +{ + fn new(options: Vec) -> Self { + Self { + options, + updated: Instant::now(), + } + } + + fn empty() -> Self { + Self { + options: vec![], + updated: Instant::now(), + } + } + + fn update(&mut self, options: Vec) { + self.options = options; + self.updated = Instant::now(); + } + + fn sync(&self, other: &mut Filtered) { + if other.updated != self.updated { + *other = self.clone(); + } + } +} + +struct Menu { + menu: menu::State, + hovered_option: Option, + new_selection: Option, + filtered_options: Filtered, +} + +#[derive(Debug, Clone)] +enum TextInputEvent { + TextChanged(String), +} + +impl Widget + for ComboBox<'_, T, Message, Theme, Renderer> +where + T: Display + Clone + 'static, + Message: Clone, + Theme: Catalog, + Renderer: text::Renderer, +{ + fn size(&self) -> Size { + Widget::::size(&self.text_input) + } + + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let is_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + + self.text_input.layout( + &mut tree.children[0], + renderer, + limits, + (!is_focused).then_some(&self.selection), + ) + } + + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::>() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(Menu:: { + menu: menu::State::new(), + filtered_options: Filtered::empty(), + hovered_option: Some(0), + new_selection: None, + }) + } + + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.text_input as &dyn Widget<_, _, _>)] + } + + fn diff(&self, _tree: &mut widget::Tree) { + // do nothing so the children don't get cleared + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let menu = tree.state.downcast_mut::>(); + + let started_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + // This is intended to check whether or not the message buffer was empty, + // since `Shell` does not expose such functionality. + let mut published_message_to_shell = false; + + // Create a new list of local messages + let mut local_messages = Vec::new(); + let mut local_shell = Shell::new(&mut local_messages); + + // Provide it to the widget + self.text_input.update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + &mut local_shell, + viewport, + ); + + if local_shell.is_event_captured() { + shell.capture_event(); + } + + shell.request_redraw_at(local_shell.redraw_request()); + shell.request_input_method(local_shell.input_method()); + + // Then finally react to them here + for message in local_messages { + let TextInputEvent::TextChanged(new_value) = message; + + if let Some(on_input) = &self.on_input { + shell.publish((on_input)(new_value.clone())); + } + + // Couple the filtered options with the `ComboBox` + // value and only recompute them when the value changes, + // instead of doing it in every `view` call + self.state.with_inner_mut(|state| { + menu.hovered_option = Some(0); + state.value = new_value; + + state.filtered_options.update( + search( + &self.state.options, + &state.option_matchers, + &state.value, + ) + .cloned() + .collect(), + ); + }); + shell.invalidate_layout(); + shell.request_redraw(); + } + + let is_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + + if is_focused { + self.state.with_inner(|state| { + if !started_focused + && let Some(on_option_hovered) = &mut self.on_option_hovered + { + let hovered_option = menu.hovered_option.unwrap_or(0); + + if let Some(option) = + state.filtered_options.options.get(hovered_option) + { + shell.publish(on_option_hovered(option.clone())); + published_message_to_shell = true; + } + } + + if let Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(named_key), + modifiers, + .. + }) = event + { + let shift_modifier = modifiers.shift(); + match (named_key, shift_modifier) { + (key::Named::Enter, _) => { + if let Some(index) = &menu.hovered_option + && let Some(option) = + state.filtered_options.options.get(*index) + { + menu.new_selection = Some(option.clone()); + } + + shell.capture_event(); + shell.request_redraw(); + } + (key::Named::ArrowUp, _) | (key::Named::Tab, true) => { + if let Some(index) = &mut menu.hovered_option { + if *index == 0 { + *index = state + .filtered_options + .options + .len() + .saturating_sub(1); + } else { + *index = index.saturating_sub(1); + } + } else { + menu.hovered_option = Some(0); + } + + if let Some(on_option_hovered) = + &mut self.on_option_hovered + && let Some(option) = + menu.hovered_option.and_then(|index| { + state + .filtered_options + .options + .get(index) + }) + { + // Notify the selection + shell.publish((on_option_hovered)( + option.clone(), + )); + published_message_to_shell = true; + } + + shell.capture_event(); + shell.request_redraw(); + } + (key::Named::ArrowDown, _) + | (key::Named::Tab, false) + if !modifiers.shift() => + { + if let Some(index) = &mut menu.hovered_option { + if *index + >= state + .filtered_options + .options + .len() + .saturating_sub(1) + { + *index = 0; + } else { + *index = index.saturating_add(1).min( + state + .filtered_options + .options + .len() + .saturating_sub(1), + ); + } + } else { + menu.hovered_option = Some(0); + } + + if let Some(on_option_hovered) = + &mut self.on_option_hovered + && let Some(option) = + menu.hovered_option.and_then(|index| { + state + .filtered_options + .options + .get(index) + }) + { + // Notify the selection + shell.publish((on_option_hovered)( + option.clone(), + )); + published_message_to_shell = true; + } + + shell.capture_event(); + shell.request_redraw(); + } + _ => {} + } + } + }); + } + + // If the overlay menu has selected something + self.state.with_inner_mut(|state| { + if let Some(selection) = menu.new_selection.take() { + // Clear the value and reset the options and menu + state.value = String::new(); + state.filtered_options.update(self.state.options.clone()); + menu.menu = menu::State::default(); + + // Notify the selection + shell.publish((self.on_selected)(selection)); + published_message_to_shell = true; + + // Unfocus the input + let mut local_messages = Vec::new(); + let mut local_shell = Shell::new(&mut local_messages); + self.text_input.update( + &mut tree.children[0], + &Event::Mouse(mouse::Event::ButtonPressed( + mouse::Button::Left, + )), + layout, + mouse::Cursor::Unavailable, + renderer, + clipboard, + &mut local_shell, + viewport, + ); + shell.request_input_method(local_shell.input_method()); + } + }); + + let is_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + + if started_focused != is_focused { + // Focus changed, invalidate widget tree to force a fresh `view` + shell.invalidate_widgets(); + + if !published_message_to_shell { + if is_focused { + if let Some(on_open) = self.on_open.take() { + shell.publish(on_open); + } + } else if let Some(on_close) = self.on_close.take() { + shell.publish(on_close); + } + } + } + } + + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.text_input.mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let is_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + + let selection = if is_focused || self.selection.is_empty() { + None + } else { + Some(&self.selection) + }; + + self.text_input.draw( + &tree.children[0], + renderer, + theme, + layout, + cursor, + selection, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'_>, + _renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + let is_focused = { + let text_input_state = tree.children[0] + .state + .downcast_ref::>(); + + text_input_state.is_focused() + }; + + if is_focused { + let Menu { + menu, + filtered_options, + hovered_option, + .. + } = tree.state.downcast_mut::>(); + + self.state.sync_filtered_options(filtered_options); + + if filtered_options.options.is_empty() { + None + } else { + let bounds = layout.bounds(); + + let mut menu = menu::Menu::new( + menu, + &filtered_options.options, + hovered_option, + |selection| { + self.state.with_inner_mut(|state| { + state.value = String::new(); + state.filtered_options.update(self.state.options.clone()); + }); + + tree.children[0] + .state + .downcast_mut::>() + .unfocus(); + + (self.on_selected)(selection) + }, + self.on_option_hovered.as_deref(), + &self.menu_class, + ) + .width(bounds.width) + .padding(self.padding) + .text_shaping(self.text_shaping); + + if let Some(font) = self.font { + menu = menu.font(font); + } + + if let Some(size) = self.size { + menu = menu.text_size(size); + } + + Some(menu.overlay( + layout.position() + translation, + *viewport, + bounds.height, + self.menu_height, + )) + } + } else { + None + } + } +} + +impl<'a, T, Message, Theme, Renderer> + From> + for Element<'a, Message, Theme, Renderer> +where + T: Display + Clone + 'static, + Message: Clone + 'a, + Theme: Catalog + 'a, + Renderer: text::Renderer + 'a, +{ + fn from(combo_box: ComboBox<'a, T, Message, Theme, Renderer>) -> Self { + Self::new(combo_box) + } +} + +/// The theme catalog of a [`ComboBox`]. +pub trait Catalog: text_input::Catalog + menu::Catalog { + /// The default class for the text input of the [`ComboBox`]. + fn default_input<'a>() -> ::Class<'a> { + ::default() + } + + /// The default class for the menu of the [`ComboBox`]. + fn default_menu<'a>() -> ::Class<'a> { + ::default() + } +} + +impl Catalog for Theme {} + +fn search<'a, T, A>( + options: impl IntoIterator + 'a, + option_matchers: impl IntoIterator + 'a, + query: &'a str, +) -> impl Iterator + 'a +where + A: AsRef + 'a, +{ + let query: Vec = query + .to_lowercase() + .split(|c: char| !c.is_ascii_alphanumeric()) + .map(String::from) + .collect(); + + options + .into_iter() + .zip(option_matchers) + // Make sure each part of the query is found in the option + .filter_map(move |(option, matcher)| { + if query.iter().all(|part| matcher.as_ref().contains(part)) { + Some(option) + } else { + None + } + }) +} + +fn build_matchers<'a, T>( + options: impl IntoIterator + 'a, +) -> Vec +where + T: Display + 'a, +{ + options.into_iter().map(build_matcher).collect() +} + +fn build_matcher(option: T) -> String +where + T: Display, +{ + let mut matcher = option.to_string(); + matcher.retain(|c| c.is_ascii_alphanumeric()); + matcher.to_lowercase() +} diff --git a/crates/vendor/iced_widget/src/container.rs b/crates/vendor/iced_widget/src/container.rs new file mode 100644 index 00000000..f83784d1 --- /dev/null +++ b/crates/vendor/iced_widget/src/container.rs @@ -0,0 +1,660 @@ +//! Containers let you align a widget inside their boundaries. +//! +//! # Example +//! ```no_run +//! # mod iced { pub mod widget { pub use iced_widget::*; } } +//! # pub type State = (); +//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +//! use iced::widget::container; +//! +//! enum Message { +//! // ... +//! } +//! +//! fn view(state: &State) -> Element<'_, Message> { +//! container("This text is centered inside a rounded box!") +//! .padding(10) +//! .center(800) +//! .style(container::rounded_box) +//! .into() +//! } +//! ``` +use crate::core::alignment::{self, Alignment}; +use crate::core::border::{self, Border}; +use crate::core::gradient::{self, Gradient}; +use crate::core::layout; +use crate::core::mouse; +use crate::core::overlay; +use crate::core::renderer; +use crate::core::theme; +use crate::core::widget::tree::{self, Tree}; +use crate::core::widget::{self, Operation}; +use crate::core::{ + self, Background, Clipboard, Color, Element, Event, Layout, Length, + Padding, Pixels, Rectangle, Shadow, Shell, Size, Theme, Vector, Widget, + color, +}; + +/// A widget that aligns its contents inside of its boundaries. +/// +/// # Example +/// ```no_run +/// # mod iced { pub mod widget { pub use iced_widget::*; } } +/// # pub type State = (); +/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; +/// use iced::widget::container; +/// +/// enum Message { +/// // ... +/// } +/// +/// fn view(state: &State) -> Element<'_, Message> { +/// container("This text is centered inside a rounded box!") +/// .padding(10) +/// .center(800) +/// .style(container::rounded_box) +/// .into() +/// } +/// ``` +pub struct Container< + 'a, + Message, + Theme = crate::Theme, + Renderer = crate::Renderer, +> where + Theme: Catalog, + Renderer: core::Renderer, +{ + id: Option, + padding: Padding, + width: Length, + height: Length, + max_width: f32, + max_height: f32, + horizontal_alignment: alignment::Horizontal, + vertical_alignment: alignment::Vertical, + clip: bool, + content: Element<'a, Message, Theme, Renderer>, + class: Theme::Class<'a>, +} + +impl<'a, Message, Theme, Renderer> Container<'a, Message, Theme, Renderer> +where + Theme: Catalog, + Renderer: core::Renderer, +{ + /// Creates a [`Container`] with the given content. + pub fn new( + content: impl Into>, + ) -> Self { + let content = content.into(); + let size = content.as_widget().size_hint(); + + Container { + id: None, + padding: Padding::ZERO, + width: size.width.fluid(), + height: size.height.fluid(), + max_width: f32::INFINITY, + max_height: f32::INFINITY, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Top, + clip: false, + class: Theme::default(), + content, + } + } + + /// Sets the [`widget::Id`] of the [`Container`]. + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Sets the [`Padding`] of the [`Container`]. + pub fn padding>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the width of the [`Container`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Container`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the maximum width of the [`Container`]. + pub fn max_width(mut self, max_width: impl Into) -> Self { + self.max_width = max_width.into().0; + self + } + + /// Sets the maximum height of the [`Container`]. + pub fn max_height(mut self, max_height: impl Into) -> Self { + self.max_height = max_height.into().0; + self + } + + /// Sets the width of the [`Container`] and centers its contents horizontally. + pub fn center_x(self, width: impl Into) -> Self { + self.width(width).align_x(alignment::Horizontal::Center) + } + + /// Sets the height of the [`Container`] and centers its contents vertically. + pub fn center_y(self, height: impl Into) -> Self { + self.height(height).align_y(alignment::Vertical::Center) + } + + /// Sets the width and height of the [`Container`] and centers its contents in + /// both the horizontal and vertical axes. + /// + /// This is equivalent to chaining [`center_x`] and [`center_y`]. + /// + /// [`center_x`]: Self::center_x + /// [`center_y`]: Self::center_y + pub fn center(self, length: impl Into) -> Self { + let length = length.into(); + + self.center_x(length).center_y(length) + } + + /// Sets the width of the [`Container`] and aligns its contents to the left. + pub fn align_left(self, width: impl Into) -> Self { + self.width(width).align_x(alignment::Horizontal::Left) + } + + /// Sets the width of the [`Container`] and aligns its contents to the right. + pub fn align_right(self, width: impl Into) -> Self { + self.width(width).align_x(alignment::Horizontal::Right) + } + + /// Sets the height of the [`Container`] and aligns its contents to the top. + pub fn align_top(self, height: impl Into) -> Self { + self.height(height).align_y(alignment::Vertical::Top) + } + + /// Sets the height of the [`Container`] and aligns its contents to the bottom. + pub fn align_bottom(self, height: impl Into) -> Self { + self.height(height).align_y(alignment::Vertical::Bottom) + } + + /// Sets the content alignment for the horizontal axis of the [`Container`]. + pub fn align_x( + mut self, + alignment: impl Into, + ) -> Self { + self.horizontal_alignment = alignment.into(); + self + } + + /// Sets the content alignment for the vertical axis of the [`Container`]. + pub fn align_y( + mut self, + alignment: impl Into, + ) -> Self { + self.vertical_alignment = alignment.into(); + self + } + + /// Sets whether the contents of the [`Container`] should be clipped on + /// overflow. + pub fn clip(mut self, clip: bool) -> Self { + self.clip = clip; + self + } + + /// Sets the style of the [`Container`]. + #[must_use] + pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self + where + Theme::Class<'a>: From>, + { + self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); + self + } + + /// Sets the style class of the [`Container`]. + #[must_use] + pub fn class(mut self, class: impl Into>) -> Self { + self.class = class.into(); + self + } +} + +impl Widget + for Container<'_, Message, Theme, Renderer> +where + Theme: Catalog, + Renderer: core::Renderer, +{ + fn tag(&self) -> tree::Tag { + self.content.as_widget().tag() + } + + fn state(&self) -> tree::State { + self.content.as_widget().state() + } + + fn children(&self) -> Vec { + self.content.as_widget().children() + } + + fn diff(&self, tree: &mut Tree) { + self.content.as_widget().diff(tree); + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout( + limits, + self.width, + self.height, + self.max_width, + self.max_height, + self.padding, + self.horizontal_alignment, + self.vertical_alignment, + |limits| { + self.content.as_widget_mut().layout(tree, renderer, limits) + }, + ) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.container(self.id.as_ref(), layout.bounds()); + operation.traverse(&mut |operation| { + self.content.as_widget_mut().operate( + tree, + layout.children().next().unwrap(), + renderer, + operation, + ); + }); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + tree, + event, + layout.children().next().unwrap(), + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + tree, + layout.children().next().unwrap(), + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + let style = theme.style(&self.class); + + if let Some(clipped_viewport) = bounds.intersection(viewport) { + draw_background(renderer, &style, bounds); + + self.content.as_widget().draw( + tree, + renderer, + theme, + &renderer::Style { + text_color: style + .text_color + .unwrap_or(renderer_style.text_color), + }, + layout.children().next().unwrap(), + cursor, + if self.clip { + &clipped_viewport + } else { + viewport + }, + ); + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + tree, + layout.children().next().unwrap(), + renderer, + viewport, + translation, + ) + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::Renderer + 'a, +{ + fn from( + container: Container<'a, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(container) + } +} + +/// Computes the layout of a [`Container`]. +pub fn layout( + limits: &layout::Limits, + width: Length, + height: Length, + max_width: f32, + max_height: f32, + padding: Padding, + horizontal_alignment: alignment::Horizontal, + vertical_alignment: alignment::Vertical, + layout_content: impl FnOnce(&layout::Limits) -> layout::Node, +) -> layout::Node { + layout::positioned( + &limits.max_width(max_width).max_height(max_height), + width, + height, + padding, + |limits| layout_content(&limits.loose()), + |content, size| { + content.align( + Alignment::from(horizontal_alignment), + Alignment::from(vertical_alignment), + size, + ) + }, + ) +} + +/// Draws the background of a [`Container`] given its [`Style`] and its `bounds`. +pub fn draw_background( + renderer: &mut Renderer, + style: &Style, + bounds: Rectangle, +) where + Renderer: core::Renderer, +{ + if style.background.is_some() + || style.border.width > 0.0 + || style.shadow.color.a > 0.0 + { + renderer.fill_quad( + renderer::Quad { + bounds, + border: style.border, + shadow: style.shadow, + snap: style.snap, + }, + style + .background + .unwrap_or(Background::Color(Color::TRANSPARENT)), + ); + } +} + +/// The appearance of a container. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Style { + /// The text [`Color`] of the container. + pub text_color: Option, + /// The [`Background`] of the container. + pub background: Option, + /// The [`Border`] of the container. + pub border: Border, + /// The [`Shadow`] of the container. + pub shadow: Shadow, + /// Whether the container should be snapped to the pixel grid. + pub snap: bool, +} + +impl Default for Style { + fn default() -> Self { + Self { + text_color: None, + background: None, + border: Border::default(), + shadow: Shadow::default(), + snap: cfg!(feature = "crisp"), + } + } +} + +impl Style { + /// Updates the text color of the [`Style`]. + pub fn color(self, color: impl Into) -> Self { + Self { + text_color: Some(color.into()), + ..self + } + } + + /// Updates the border of the [`Style`]. + pub fn border(self, border: impl Into) -> Self { + Self { + border: border.into(), + ..self + } + } + + /// Updates the background of the [`Style`]. + pub fn background(self, background: impl Into) -> Self { + Self { + background: Some(background.into()), + ..self + } + } + + /// Updates the shadow of the [`Style`]. + pub fn shadow(self, shadow: impl Into) -> Self { + Self { + shadow: shadow.into(), + ..self + } + } +} + +impl From for Style { + fn from(color: Color) -> Self { + Self::default().background(color) + } +} + +impl From for Style { + fn from(gradient: Gradient) -> Self { + Self::default().background(gradient) + } +} + +impl From for Style { + fn from(gradient: gradient::Linear) -> Self { + Self::default().background(gradient) + } +} + +/// The theme catalog of a [`Container`]. +pub trait Catalog { + /// The item class of the [`Catalog`]. + type Class<'a>; + + /// The default class produced by the [`Catalog`]. + fn default<'a>() -> Self::Class<'a>; + + /// The [`Style`] of a class with the given status. + fn style(&self, class: &Self::Class<'_>) -> Style; +} + +/// A styling function for a [`Container`]. +pub type StyleFn<'a, Theme> = Box Style + 'a>; + +impl From