diff --git a/.gitattributes b/.gitattributes index 43ce420c..0974a9e7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -86,3 +86,6 @@ harness/experiments/analysis-source3-bridge-windows-20260913/** -text whitespace harness/scenario-analysis-version-integration-review-20260913.md -text whitespace=cr-at-eol harness/scenario-analysis-version-integration-inputs-20260913.json -text whitespace=cr-at-eol harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol + +# Preserve the pinned Iced dependency bytes and upstream manifest. +crates/vendor/iced_widget/** -text whitespace=cr-at-eol diff --git a/Cargo.lock b/Cargo.lock index 8719ac1e..c413b9d3 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", 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/src/app.rs b/crates/oa-desktop/src/app.rs index 50b38d20..bec54c0a 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -3436,6 +3436,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); } @@ -5340,6 +5351,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/source_rename/navigation_union_tests/palette_union.rs b/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs index be5f7e0b..959e48ba 100644 --- a/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs +++ b/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs @@ -165,6 +165,7 @@ fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() { first_measurement(&mut app, &found); } +#[cfg(debug_assertions)] #[test] fn stale_compact_drawer_controls_preserve_current_navigation_and_evidence() { use crate::app::text_workspace::{DrawerTab, Event as TextEvent}; @@ -218,25 +219,37 @@ fn stale_compact_drawer_controls_preserve_current_navigation_and_evidence() { } } +#[cfg(debug_assertions)] #[test] fn current_compact_drawer_controls_and_mounted_resize_remain_live() { use crate::app::text_workspace::{DrawerTab, Event as TextEvent}; let mut app = navigation_fixture(); let accepted = app.document.snapshot(); let raw = app.source_workspace.bundle(); + navigate(&mut app); + app.page = Page::Text; + assert!(app.pending_diagram_reveal.is_some()); + let epoch = app.allocation_drag.epoch; let show = Message::TextWorkspace(TextEvent::ShowDrawer( app.text_drawer_context(), DrawerTab::Status, )); send(&mut app, show); assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status)); + assert!(app.pending_diagram_reveal.is_none()); + assert!(app.diagram_navigation_focus.is_none()); + assert!(app.allocation_drag.epoch > epoch); + let epoch = app.allocation_drag.epoch; let height = app.text_workspace.drawer_height(); let resize = crate::views::captured_text_drawer_resize(&app); send(&mut app, resize); assert_eq!(app.text_workspace.drawer_height(), height + 60.); + assert!(app.allocation_drag.epoch > epoch); + let epoch = app.allocation_drag.epoch; let hide = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context())); send(&mut app, hide); assert!(app.text_workspace.drawer().is_none()); + assert!(app.allocation_drag.epoch > epoch); assert_eq!(app.source_workspace.bundle(), raw); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); assert!(!app.document.can_undo() && !app.document.can_redo()); diff --git a/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs b/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs index 0f9be62f..f98a6978 100644 --- a/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs +++ b/crates/oa-desktop/src/app/source_rename/preview_scroll_tests.rs @@ -512,3 +512,45 @@ async fn details_remount_restores_wheel_offset_before_any_rescroll() { assert_eq!(evidence(&app), before); } } + +#[tokio::test] +async fn diagnostic_publication_during_rename_keeps_actual_preview_report_passive() { + let mut app = paged_fixture(); + app.publish_source_diagnostics(app.source_diagnostics.clone()); + let publication = app.text_workspace.diagnostic_publication().unwrap(); + let before = evidence(&app); + let interaction = app.source_symbols.interaction; + let camera = app.camera_boundary(); + let allocation = app.allocation_boundary(); + let cursor = app.source_editor.cursor(); + let mut mounted = Mounted::new(&app); + let reports = mounted.wheel(&app); + assert_eq!(reports.len(), 1); + let Message::SourceRename(Event::PreviewScrolled(_, _, _, offset)) = &reports[0] else { + panic!("actual wheel must publish the retained preview measurement"); + }; + assert!(offset.is_finite() && *offset > 0.); + app.publish_source_diagnostics(app.source_diagnostics.clone()); + let current = app.text_workspace.diagnostic_publication().unwrap(); + assert_ne!(current.generation(), publication.generation()); + assert_eq!(current.source(), publication.source()); + assert!(app.source_annotation_snapshot().is_some()); + assert!( + !app.source_navigation_available(), + "rename still owns modal input" + ); + for message in reports.iter().cloned() { + let task = app.update(message); + assert_eq!(mounted.execute(&app, task).await, 0); + } + assert_eq!( + app.source_rename.form.as_ref().unwrap().preview_offset, + *offset + ); + assert_eq!(mounted.offset(&app), offset.round()); + assert_eq!(app.source_symbols.interaction, interaction); + assert!(app.camera_boundary() == camera); + assert!(app.allocation_boundary() == allocation); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(evidence(&app), before); +} diff --git a/crates/oa-desktop/src/app/source_symbols.rs b/crates/oa-desktop/src/app/source_symbols.rs index fa44e2ea..2b1d52de 100644 --- a/crates/oa-desktop/src/app/source_symbols.rs +++ b/crates/oa-desktop/src/app/source_symbols.rs @@ -126,6 +126,20 @@ impl App { && self.with_source_input(|input| snapshot.matches(input)) } + /// Retained rows remain readable after departure, but are current only while + /// their captured source bytes still match the complete workspace. + pub fn source_results_current(&self) -> bool { + self.source_symbols + .results + .as_ref() + .is_some_and(|results| results.stamp.snapshot == self.source_key()) + && self + .source_symbols + .cache + .as_ref() + .is_some_and(|cache| self.source_snapshot_matches(cache)) + } + pub fn source_navigation_available(&self) -> bool { self.page == Page::Text && !self.source_rename.open() diff --git a/crates/oa-desktop/src/app/source_symbols/tests.rs b/crates/oa-desktop/src/app/source_symbols/tests.rs index 7b4b056b..a165e166 100644 --- a/crates/oa-desktop/src/app/source_symbols/tests.rs +++ b/crates/oa-desktop/src/app/source_symbols/tests.rs @@ -74,190 +74,6 @@ fn select_effective_pump(app: &mut App, line: bool) -> String { selected } -#[test] -fn effective_selection_survives_file_roundtrip_and_definition_back() { - for line in [false, true] { - for definition in [false, true] { - let mut app = fixture(); - let selected = select_effective_pump(&mut app, line); - let accepted = app.document.snapshot(); - let bundle = app.source_workspace.bundle(); - let revision = app.source_workspace.revision(); - if definition { - let results = finish(&mut app, query::Action::Definition); - assert_eq!(results.disposition, query::Disposition::Direct); - reveal(&mut app); - assert_eq!(app.source_workspace.active, DEFINITIONS); - symbol(&mut app, nav::SymbolEvent::Back); - reveal(&mut app); - assert!(app.source_symbols.back.is_empty()); - } else { - show(&mut app, DEFINITIONS); - show(&mut app, USES); - } - assert_eq!(app.source_workspace.active, USES); - assert_eq!( - app.source_editor.selection().as_deref(), - Some(selected.as_str()) - ); - assert_eq!(app.source_workspace.bundle(), bundle); - assert_eq!(app.source_workspace.revision(), revision); - assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); - assert!(!app.source_workspace.undo(false) && !app.source_workspace.undo(true)); - assert!(!app.document.can_undo() && !app.document.can_redo()); - let expected = if line { - bundle.files[USES].replacen(&selected, "x", 1) - } else { - bundle.files[USES].replacen("source @pump", "source @x", 1) - }; - send( - &mut app, - Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))), - ); - assert_eq!(app.source_workspace.text(), expected); - assert_eq!(app.source_editor.text(), expected); - assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); - } - } -} - -#[test] -fn effective_selection_is_untouched_by_noop_missing_invalid_or_cancelled_navigation() { - for variant in 0..4 { - let mut app = fixture(); - let selected = select_effective_pump(&mut app, false); - let raw_cursor = app.source_editor.cursor(); - let bundle = app.source_workspace.bundle(); - let revision = app.source_workspace.revision(); - let accepted = app.document.snapshot(); - match variant { - 0 => send(&mut app, Message::SourceFile(USES.into())), - 1 => send(&mut app, Message::SourceFile("missing.arcadia".into())), - 2 => app.source_symbol_jump( - DEFINITIONS, - query::Position { - line: 999, - byte_column: 0, - }, - query::Position { - line: 999, - byte_column: 1, - }, - ), - _ => { - let request = begin(&mut app, query::Action::Definition); - let ready = completion(&app, request); - assert_eq!( - app.source_editor.selection().as_deref(), - Some(selected.as_str()) - ); - send(&mut app, Message::SourceFile(USES.into())); - send(&mut app, Message::SourceSymbolsReady(ready)); - reveal(&mut app); - } - } - assert_eq!(app.source_workspace.active, USES); - assert_eq!( - app.source_editor.cursor(), - raw_cursor, - "live selection mode must not normalize" - ); - assert_eq!( - app.source_editor.selection().as_deref(), - Some(selected.as_str()) - ); - assert_eq!(app.source_workspace.bundle(), bundle); - assert_eq!(app.source_workspace.revision(), revision); - assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); - } -} - -#[test] -fn changed_active_origin_back_keeps_current_effective_selection() { - let mut app = fixture(); - let old_generation = app.source_workspace.files[USES].generation(); - let old_cursor = app.source_editor.cursor(); - let original = app.source_workspace.text().to_owned(); - app.source_editor - .perform(text_editor::Action::Move(text_editor::Motion::Left)); - app.source_editor.move_to(text_editor::Cursor { - position: text_editor::Position { line: 0, column: 3 }, - selection: None, - }); - send( - &mut app, - Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))), - ); - let mut expected_edit = original; - expected_edit.insert(3, 'x'); - assert_eq!(app.source_workspace.text(), expected_edit); - let selected = select_effective_pump(&mut app, true); - let bundle = app.source_workspace.bundle(); - let revision = app.source_workspace.revision(); - app.source_symbols.back.push(nav::HistoryEntry { - session: app.source_workspace.session(), - path: USES.into(), - generation: old_generation, - cursor: old_cursor, - }); - symbol(&mut app, nav::SymbolEvent::Back); - reveal(&mut app); - assert_eq!( - app.source_editor.selection().as_deref(), - Some(selected.as_str()) - ); - assert!( - app.source_symbols - .notice - .as_deref() - .unwrap() - .contains("draft changed") - ); - assert_eq!(app.source_workspace.bundle(), bundle); - assert_eq!(app.source_workspace.revision(), revision); -} - -#[test] -fn source_creation_preserves_outgoing_selection_and_undisplayed_entry_cursor() { - let mut app = fixture(); - let selected = select_effective_pump(&mut app, true); - let bundle = app.source_workspace.bundle(); - let entry_cursor = app.source_workspace.files[&bundle.entry].cursor; - let accepted = app.document.snapshot(); - send(&mut app, Message::SourceNewPath("new.arcadia".into())); - send(&mut app, Message::SourceCreateFile); - assert_eq!(app.source_workspace.active, "new.arcadia"); - assert_eq!( - app.source_workspace.files[&bundle.entry].cursor, - entry_cursor - ); - assert_eq!(app.source_workspace.files[USES].text, bundle.files[USES]); - assert!( - app.source_workspace.files[&bundle.entry] - .text - .contains("include \"new.arcadia\";") - ); - assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); - show(&mut app, USES); - assert_eq!( - app.source_editor.selection().as_deref(), - Some(selected.as_str()) - ); - select_effective_pump(&mut app, true); - let raw = app.source_editor.cursor(); - send( - &mut app, - Message::SourceNewPath("../invalid.arcadia".into()), - ); - send(&mut app, Message::SourceCreateFile); - assert_eq!(app.source_workspace.active, USES); - assert_eq!(app.source_editor.cursor(), raw); - assert_eq!( - app.source_editor.selection().as_deref(), - Some(selected.as_str()) - ); -} - fn send(app: &mut App, message: Message) { // Dropping the returned Task does not poll its worker/lint future. Completion // below is deterministic and still traverses the real App message handler. @@ -2272,3 +2088,31 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil assert_eq!(app.source_workspace.bundle(), raw); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); } + +#[test] +fn same_key_changed_bytes_mark_retained_rows_stale_before_choice_boundaries() { + for path in [USES, DEFINITIONS] { + let mut app = fixture(); + let results = finish(&mut app, query::Action::References); + assert!(app.source_results_current()); + #[cfg(debug_assertions)] + assert!(!crate::views::captured_source_reference_stale_summary(&app)); + let captured = choice(&app, results.stamp, true, 0); + let key = app.source_key(); + // Isolate byte authority from revision authority in both the displayed + // and an inactive buffer. Views share this exact currentness decision. + app.source_workspace + .files + .get_mut(path) + .unwrap() + .text + .push_str("\r\n// exact bytes changed"); + assert_eq!(app.source_key(), key); + assert!(!app.source_results_current()); + #[cfg(debug_assertions)] + assert!(crate::views::captured_source_reference_stale_summary(&app)); + app.queue_source_reveal(app.source_editor.cursor(), false); + assert!(app.source_reveal_ticket().is_some()); + assert_stale_envelopes_preserve_current(&mut app, &[captured]); + } +} diff --git a/crates/oa-desktop/src/app/text_workspace.rs b/crates/oa-desktop/src/app/text_workspace.rs index 439420d3..7efb23e7 100644 --- a/crates/oa-desktop/src/app/text_workspace.rs +++ b/crates/oa-desktop/src/app/text_workspace.rs @@ -399,6 +399,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. @@ -468,6 +486,7 @@ impl App { let Some(popup_token) = self.menu_sequence.checked_add(1) else { return Task::none(); }; + self.text_presentation_boundary(Event::OpenMenu(request.clone())); self.source_interaction(); let commands = [ Command::Find, @@ -513,6 +532,7 @@ impl App { .as_ref() .is_some_and(|menu| menu.token == token) { + self.text_presentation_boundary(Event::DismissMenu(token)); self.text_workspace.menu = None; self.source_interaction(); } @@ -551,12 +571,14 @@ impl App { if context != self.text_drawer_context() || !self.source_navigation_available() { return Task::none(); } + self.text_presentation_boundary(Event::ShowDrawer(context, tab)); return self.show_text_drawer(Some(tab)); } Event::HideDrawer(context) => { if context != self.text_drawer_context() || !self.source_navigation_available() { return Task::none(); } + self.text_presentation_boundary(Event::HideDrawer(context)); return self.show_text_drawer(None); } Event::ResizeDrawer(context, height) => { @@ -567,15 +589,22 @@ impl App { // The view applies the measured editor-allocation bound. let height = height.clamp(180., 1200.); if height != self.text_workspace.drawer_height { + self.text_presentation_boundary(Event::ResizeDrawer(context, height)); self.source_interaction(); self.text_workspace.drawer_height = height; } } } Event::ToggleFile { stamp, visit, path } => { + let captured = Event::ToggleFile { + stamp, + visit, + path: path.clone(), + }; if self.source_navigation_available() && self.text_workspace.toggle_file(stamp, visit, path) { + self.text_presentation_boundary(captured); self.source_interaction(); } } @@ -598,6 +627,7 @@ impl App { { return Task::none(); } + self.text_presentation_boundary(Event::Diagnostic(target.clone())); self.source_interaction(); self.jump_to_source(target.location); } @@ -607,9 +637,22 @@ impl App { fn dispatch_text_action(&mut self, action: Action) -> Task { match action { Action::Dispatch(message) => self.update(*message), - Action::Drawer(tab) => self.show_text_drawer(Some(tab)), + Action::Drawer(tab) => { + self.text_presentation_boundary(Event::ShowDrawer(self.text_drawer_context(), tab)); + self.show_text_drawer(Some(tab)) + } } } + /// Local Text presentation changes do not mutate a camera, document or route. + /// After their own authority check, they still supersede pending navigation + /// and allocation through the root's existing input boundary hooks. Source + /// commands use update instead; passive measurements never enter this hook. + fn text_presentation_boundary(&mut self, event: Event) { + let message = Message::TextWorkspace(event); + self.reconcile_pending_diagram_reveal(&message); + self.allocation_before(&message); + } + pub(super) fn show_text_drawer(&mut self, drawer: Option) -> Task { self.source_interaction(); self.text_workspace.show_drawer(drawer); diff --git a/crates/oa-desktop/src/app/text_workspace/tests.rs b/crates/oa-desktop/src/app/text_workspace/tests.rs index 3c24cb42..f15ec7cf 100644 --- a/crates/oa-desktop/src/app/text_workspace/tests.rs +++ b/crates/oa-desktop/src/app/text_workspace/tests.rs @@ -670,3 +670,208 @@ 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())); + } +} + +#[test] +fn drawer_document_session_and_visit_guards_are_independently_required() { + for component in 0..3 { + for action in 0..3 { + let mut app = fixture(); + app.text_workspace.show_drawer(Some(DrawerTab::Status)); + let mut captured = app.text_drawer_context(); + match component { + 0 => captured.document = Id::new_v4(), + 1 => captured.session = Id::new_v4(), + _ => captured.visit = Id::new_v4(), + } + app.queue_source_reveal(app.source_editor.cursor(), false); + let reveal = app.source_reveal_ticket(); + assert!(reveal.is_some()); + let interaction = app.source_symbols.interaction; + let cursor = app.source_editor.cursor(); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let current = app.text_drawer_context(); + let height = app.text_workspace.drawer_height(); + let event = match action { + 0 => Event::ShowDrawer(captured, DrawerTab::Problems), + 1 => Event::HideDrawer(captured), + _ => Event::ResizeDrawer(captured, height + 60.), + }; + send(&mut app, event); + assert_eq!(app.text_drawer_context(), current); + assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status)); + assert_eq!(app.text_workspace.drawer_height(), height); + assert_eq!(app.source_reveal_ticket(), reveal); + assert_eq!(app.source_symbols.interaction, interaction); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + } +} + +#[cfg(debug_assertions)] +#[test] +fn actual_chooser_blocks_diagnostic_actions_and_mounted_hover_until_closed() { + use oa_core::{language::Diagnostic, source_project::SourceDiagnostic, validation::Severity}; + for republish in [false, true] { + let mut app = fixture(); + assert!(app.source_workspace.text().starts_with("arcadia")); + app.publish_source_diagnostics(vec![SourceDiagnostic { + path: app.source_workspace.active.clone(), + diagnostic: Diagnostic { + severity: Severity::Warning, + code: "union".into(), + message: "Union diagnostic details".into(), + object_id: None, + field: None, + line: 1, + column: 1, + span: Span { start: 0, end: 7 }, + }, + related: Vec::new(), + }]); + let show = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems); + send(&mut app, show); + let old_target = app.text_diagnostic_message(location(&app, 0, 7)).unwrap(); + let mut mounted = crate::source_annotations::AppMounted::new( + app.source_annotation_snapshot().unwrap(), + app.source_navigation_available(), + app.source_symbols.raw_interaction.clone(), + ); + mounted.assert_hover(true); + let open = app.diagram_usage_message(app.project().id); + let _ = app.update(open); + let token = app + .diagram_usages + .as_ref() + .expect("actual chooser opened") + .token; + assert!(!app.source_navigation_available()); + let accepted = app.document.snapshot(); + let raw = app.source_workspace.bundle(); + let cursor = app.source_editor.cursor(); + let interaction = app.source_symbols.interaction; + let reveal = app.source_reveal_ticket(); + if republish { + app.publish_source_diagnostics(app.source_diagnostics.clone()); + } + mounted.refresh( + app.source_annotation_snapshot().unwrap(), + app.source_navigation_available(), + ); + mounted.assert_hover(false); + let _ = app.update(old_target.clone()); + assert_eq!(app.source_symbols.interaction, interaction); + assert_eq!(app.source_reveal_ticket(), reveal); + assert_eq!(app.diagram_usages.as_ref().unwrap().token, token); + assert_eq!(app.diagram_lookup_in_flight, Some(token)); + assert_eq!(app.source_editor.cursor(), cursor); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + let _ = app.update(Message::DiagramUsage(crate::diagram_usage::Event::Close( + token, + ))); + assert!(app.source_navigation_available()); + mounted.refresh( + app.source_annotation_snapshot().unwrap(), + app.source_navigation_available(), + ); + mounted.assert_hover(true); + if republish { + let interaction = app.source_symbols.interaction; + let reveal = app.source_reveal_ticket(); + let _ = app.update(old_target); + assert_eq!( + app.source_symbols.interaction, interaction, + "old publication stays stale after modal closes" + ); + assert_eq!(app.source_reveal_ticket(), reveal); + } + let target = app.text_diagnostic_message(location(&app, 0, 7)).unwrap(); + let _ = app.update(target); + assert!( + app.source_reveal_ticket().is_some(), + "fresh admitted location still navigates" + ); + assert_eq!(app.source_workspace.bundle(), raw); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index 51bb8be4..282766b3 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -27,6 +27,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..b5a3ff6d --- /dev/null +++ b/crates/oa-desktop/src/source_annotations.rs @@ -0,0 +1,312 @@ +//! 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; + +#[cfg(all(test, debug_assertions))] +pub(crate) use widget::tests::AppMounted; 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..7f2c9edd --- /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))] +pub(crate) 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..c991a934 --- /dev/null +++ b/crates/oa-desktop/src/source_annotations/widget/tests.rs @@ -0,0 +1,1094 @@ +//! 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" + ); + } +} + +/// Composition witness using admitted App snapshots and the App's actual raw +/// input clock. Content is copied into the CPU renderer's real graphics editor; +/// this is not a native production-renderer or sensor-topology claim. +pub(crate) struct AppMounted(Mounted); +impl AppMounted { + pub(crate) fn new(snapshot: Snapshot<'_>, hover_enabled: bool, clock: Arc) -> Self { + let mut mounted = Self(Mounted::new(&snapshot.source)); + mounted.0.clock = clock; + mounted.refresh(snapshot, hover_enabled); + mounted + } + pub(crate) fn refresh(&mut self, snapshot: Snapshot<'_>, hover_enabled: bool) { + assert_eq!(self.0.content.text(), snapshot.source.as_ref()); + self.0.source = snapshot.source; + self.0.path = snapshot.path.to_owned(); + self.0.diagnostics = snapshot.diagnostics.to_vec(); + self.0.publication = Some(snapshot.publication); + self.0.hover_enabled = hover_enabled; + } + pub(crate) fn assert_hover(&mut self, expected: bool) { + let frame = self.0.draw(mouse::Cursor::Unavailable); + let point = frame.point(0, 2); + let frame = passive(&mut self.0, Some(point)); + assert!( + frame.decoration_quads().count() > 0, + "current source marks remain painted" + ); + if expected { + assert!(frame.tooltip().contains("Union diagnostic details")); + assert!( + frame.deferred_text().iter().any(|text| text + .as_ref() + .is_some_and(|text| text.contains("Union diagnostic details"))), + "actual weak paragraph remains alive after draw returns" + ); + } else { + assert!(frame.paragraphs.is_empty()); + assert!(frame.deferred_paragraphs.is_empty()); + } + } +} diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index bde08594..a355c65c 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -1158,7 +1158,16 @@ fn source(app: &App) -> View<'_, Message> { publication.map(|p| (p.generation(), app.text_workspace.drawer_visit())), diagnostics, ); - text_workspace::view(app, source_symbols::panel(app), guard.into()) + // The complete byte/evidence comparison is shared by the summary and rows, + // once per visible References rebuild; no parser or second query is started. + let references_current = + app.text_workspace.drawer() == Some(DrawerTab::References) && app.source_results_current(); + text_workspace::view( + app, + source_symbols::panel(app, references_current), + guard.into(), + references_current, + ) } pub(crate) fn source_binding( @@ -1580,3 +1589,8 @@ mod tests { pub(crate) fn captured_text_drawer_resize(app: &App) -> Message { text_workspace::capture_drawer_resize(app) } + +#[cfg(all(test, debug_assertions))] +pub(crate) fn captured_source_reference_stale_summary(app: &App) -> bool { + text_workspace::summary_visibility_tests::rendered_stale(app) +} diff --git a/crates/oa-desktop/src/views/source_symbols.rs b/crates/oa-desktop/src/views/source_symbols.rs index b145405b..ae594c62 100644 --- a/crates/oa-desktop/src/views/source_symbols.rs +++ b/crates/oa-desktop/src/views/source_symbols.rs @@ -11,7 +11,7 @@ use iced::{ widget::{button, column, container, scrollable, text, tooltip}, }; -pub(super) fn panel(app: &App) -> Element<'_, Message> { +pub(super) fn panel(app: &App, current: bool) -> Element<'_, Message> { use crate::app::text_workspace::{DrawerTab, Event}; use std::collections::BTreeSet; let state = &app.source_symbols; @@ -22,7 +22,7 @@ pub(super) fn panel(app: &App) -> Element<'_, Message> { content = content.push(text(notice.clone()).size(12)); } if visible && let Some(results) = &state.results { - let stale = results.stamp.snapshot != app.source_key(); + let stale = !current; let enabled = !stale && app.source_navigation_available(); let files: BTreeSet<_> = results .definitions diff --git a/crates/oa-desktop/src/views/text_workspace.rs b/crates/oa-desktop/src/views/text_workspace.rs index 84cb8b12..7fa09536 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 @@ -23,6 +24,7 @@ pub(super) fn view<'a>( app: &'a App, references: Element<'a, Message>, problems: Element<'a, Message>, + references_current: bool, ) -> Element<'a, Message> { let tab = app.text_workspace.drawer(); let measured_drawer = Rc::new(Cell::new(DRAWER_HEIGHT)); @@ -63,12 +65,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(), @@ -84,7 +93,7 @@ pub(super) fn view<'a>( }); let (errors, warnings) = diagnostic_counts(app); let summary: Element<'a, Message> = match tab { - Some(DrawerTab::References) => reference_summary(app), + Some(DrawerTab::References) => reference_summary(app, references_current), Some(DrawerTab::Problems) => text(format!( "{errors} source errors · {warnings} warnings · choose a location for details" )) @@ -306,6 +315,7 @@ fn find_bar(app: &App) -> Element<'_, Message> { fn reference_summary<'a, Renderer: iced::advanced::text::Renderer + 'a>( app: &'a App, + current: bool, ) -> Element<'a, Message, iced::Theme, Renderer> { let Some(results) = &app.source_symbols.results else { return text(if app.source_symbols.in_flight.is_some() { @@ -326,7 +336,7 @@ fn reference_summary<'a, Renderer: iced::advanced::text::Renderer + 'a>( results.total_definitions, results.total_references ); let mut flags = Vec::new(); - if results.stamp.snapshot != app.source_key() { + if !current { flags.push("Stale"); } if results.scope.partial() { @@ -1104,7 +1114,7 @@ mod tests { } #[cfg(all(test, debug_assertions))] -mod summary_visibility_tests; +pub(crate) mod summary_visibility_tests; /// The same retained layout and Alt+PageDown publisher as the production view; /// only its five content slots are inert for this CPU integration witness. diff --git a/crates/oa-desktop/src/views/text_workspace/summary_visibility_tests.rs b/crates/oa-desktop/src/views/text_workspace/summary_visibility_tests.rs index 81d39838..078dd6a0 100644 --- a/crates/oa-desktop/src/views/text_workspace/summary_visibility_tests.rs +++ b/crates/oa-desktop/src/views/text_workspace/summary_visibility_tests.rs @@ -117,6 +117,7 @@ const FLAGS: [&str; 5] = ["Stale", "Partial", "Collision", "Detached", "Unverifi fn draw_and_check_flags( content: Element<'_, Message, iced::Theme, Recorder>, width: f32, + flags: &[&str], ) -> Vec { let viewport = Rectangle::with_size(Size::new(width, 24.)); let mut renderer = Recorder { @@ -143,7 +144,7 @@ fn draw_and_check_flags( &viewport, ); let mut failures = Vec::new(); - for flag in FLAGS { + for &flag in flags { for byte in 0..flag.len() { let matching: Vec<_> = renderer .clusters @@ -221,14 +222,18 @@ fn all_reference_exceptions_fit_allocated_width_with_wide_alias_and_large_counts omitted_references: 100_000, })); for width in [840., 720.] { - let positive = draw_and_check_flags(reference_summary(&app), width); + let positive = draw_and_check_flags( + reference_summary(&app, app.source_results_current()), + width, + &FLAGS, + ); assert!( positive.is_empty(), "reserved flags, width {width}, alias {alias:?}: {positive:?}" ); // Observe the exact old presentation without predicting font widths. // This is emitted under --nocapture, whether it clips or fits. - let old = draw_and_check_flags(old_summary(old_value.clone()), width); + let old = draw_and_check_flags(old_summary(old_value.clone()), width, &FLAGS); println!( "old alias-first summary width={width} alias={alias:?}: {} missing/clipped flag-byte checks; first={:?}", old.len(), @@ -240,9 +245,18 @@ fn all_reference_exceptions_fit_allocated_width_with_wide_alias_and_large_counts // bound: require the same evaluator to reject a clipped old-style paragraph. // This is not evidence of a production/native overflow at 840 pixels. let stress = format!("@{} · {}", "W".repeat(256), FLAGS.join(" · ")); - let rejected = draw_and_check_flags(old_summary(stress), 720.); + let rejected = draw_and_check_flags(old_summary(stress), 720., &FLAGS); assert!( !rejected.is_empty(), "negative control must detect missing/clipped warning text" ); } + +pub(crate) fn rendered_stale(app: &App) -> bool { + draw_and_check_flags( + reference_summary(app, app.source_results_current()), + 720., + &["Stale"], + ) + .is_empty() +} 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