From 2721f5e7ea96fac98a918d195aa6945de30b8007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Tue, 8 Sep 2026 22:48:29 -0400 Subject: [PATCH] Add Acrobat-style PDF bookmarks navigation. Read the document /Outlines tree into a nested Bookmarks pane, jump to the destination page on click, and keep expansion while paging. Verified with the sample PDF and [MS-RDPBCGR]. --- apps/pdf-viewer/README.md | 17 +- .../generated/.avalonia-viewmodel.owned.json | 4 +- .../generated/generated_view_models.rs | 114 ++++- .../generated/view-model.contract.md | 20 + .../Generated/.avalonia-viewmodel.owned.json | 14 +- .../Generated/MainViewModelAdapter.g.cs | 112 +++++ .../managed/Generated/MainViewModelMenus.g.cs | 11 + .../Generated/MainViewModelMetadata.g.cs | 4 + .../OutlineItemViewModelAdapter.g.cs | 451 ++++++++++++++++++ .../OutlineItemViewModelMetadata.g.cs | 26 + .../pdf-viewer/managed/Views/MainWindow.axaml | 137 +++++- apps/pdf-viewer/src/main.rs | 142 +++++- apps/pdf-viewer/src/pdf.rs | 156 +++++- apps/pdf-viewer/tests/test-bundle.ps1 | 70 ++- apps/pdf-viewer/view-model.ir.json | 104 ++++ 15 files changed, 1339 insertions(+), 43 deletions(-) create mode 100644 apps/pdf-viewer/managed/Generated/OutlineItemViewModelAdapter.g.cs create mode 100644 apps/pdf-viewer/managed/Generated/OutlineItemViewModelMetadata.g.cs diff --git a/apps/pdf-viewer/README.md b/apps/pdf-viewer/README.md index ad08afd..413f269 100644 --- a/apps/pdf-viewer/README.md +++ b/apps/pdf-viewer/README.md @@ -15,6 +15,11 @@ temporary directory and renders its first page. Passing a local PDF path opens that document instead. The **Open PDF** command uses Avalonia's platform file picker, and **Previous**/**Next** navigate rendered pages. +The left **Bookmarks** pane reads the PDF outline (table of contents) the same +way Acrobat Reader does: nested entries expand in place, and clicking a +bookmark seeks to that page. **View → Bookmarks** (Ctrl+B) shows or hides the +pane. Documents without an `/Outlines` dictionary show an empty-state message. + ## Build on Windows x64 From the repository root: @@ -40,12 +45,22 @@ Pop-Location ``` After building, the Windows UI Automation smoke test exercises startup sample -generation, page rendering, next/previous navigation and natural shutdown: +generation, page rendering, bookmark jumps, next/previous navigation and +natural shutdown: ```powershell powershell.exe -NoProfile -STA -ExecutionPolicy Bypass ` -File ./apps/pdf-viewer/tests/test-bundle.ps1 ``` +To exercise Acrobat-style table-of-contents navigation on a real document: + +```powershell +powershell.exe -NoProfile -STA -ExecutionPolicy Bypass ` + -File ./apps/pdf-viewer/tests/test-bundle.ps1 ` + -PdfPath 'C:\Users\mamoreau\Downloads\[MS-RDPBCGR].pdf' ` + -TimeoutSeconds 90 +``` + The app is a portable Win32 GUI bundle, not an installer. File-association metadata snippets are under `file-associations/`. diff --git a/apps/pdf-viewer/generated/.avalonia-viewmodel.owned.json b/apps/pdf-viewer/generated/.avalonia-viewmodel.owned.json index d69f432..238d991 100644 --- a/apps/pdf-viewer/generated/.avalonia-viewmodel.owned.json +++ b/apps/pdf-viewer/generated/.avalonia-viewmodel.owned.json @@ -3,7 +3,7 @@ "files": [ { "path": "generated_view_models.rs", - "sha256": "e289e67a071703b88622d0668d4f5ea107b45c9a6a23cc131c852a08860294d6" + "sha256": "56d824b36dce2cae7e11aeac53c97c38d91f492437313f2e94ab650c9df83d58" }, { "path": "RustViewRegistry.g.cs", @@ -11,7 +11,7 @@ }, { "path": "view-model.contract.md", - "sha256": "8e307c0c44e37d1aec215483bf9c4e6507e53a3051e7fead72f594c63d158a8d" + "sha256": "46b156df049a405b6dbc1e7a684003ef8be910ddbe3ac9c89ee6853b62809b12" } ] } diff --git a/apps/pdf-viewer/generated/generated_view_models.rs b/apps/pdf-viewer/generated/generated_view_models.rs index 66d265f..131663d 100644 --- a/apps/pdf-viewer/generated/generated_view_models.rs +++ b/apps/pdf-viewer/generated/generated_view_models.rs @@ -23,12 +23,21 @@ impl MainViewModelSink { pub fn set_search_match_label(&self, value: impl AsRef) -> crate::Result<()> { self.0.set_string(12, value) } pub fn set_can_go_previous_match(&self, value: bool) -> crate::Result<()> { self.0.set_boolean(13, value) } pub fn set_can_go_next_match(&self, value: bool) -> crate::Result<()> { self.0.set_boolean(14, value) } + pub fn set_outline_visible(&self, value: bool) -> crate::Result<()> { self.0.set_boolean(15, value) } + pub fn set_has_outline(&self, value: bool) -> crate::Result<()> { self.0.set_boolean(16, value) } + pub fn set_outline_status(&self, value: impl AsRef) -> crate::Result<()> { self.0.set_string(17, value) } pub fn add_recent_files(&self, value: impl AsRef) -> crate::Result<()> { self.0.add_string(1, value) } pub fn insert_recent_files(&self, index: i32, value: impl AsRef) -> crate::Result<()> { self.0.insert_string(1, index, value) } pub fn replace_recent_files(&self, index: i32, value: impl AsRef) -> crate::Result<()> { self.0.replace_string(1, index, value) } + pub fn add_outline(&self, value: impl OutlineItemViewModel) -> crate::Result<()> { self.0.add_model(2, OutlineItemViewModelDispatch { model: value }) } + pub fn insert_outline(&self, index: i32, value: impl OutlineItemViewModel) -> crate::Result<()> { self.0.insert_model(2, index, OutlineItemViewModelDispatch { model: value }) } + pub fn replace_outline(&self, index: i32, value: impl OutlineItemViewModel) -> crate::Result<()> { self.0.replace_model(2, index, OutlineItemViewModelDispatch { model: value }) } pub fn remove_recent_files(&self, index: i32) -> crate::Result<()> { self.0.remove_string_at(1, index) } pub fn move_recent_files(&self, from_index: i32, to_index: i32) -> crate::Result<()> { self.0.move_string_item(1, from_index, to_index) } pub fn clear_recent_files(&self) -> crate::Result<()> { self.0.clear_string_collection(1) } + pub fn remove_outline(&self, index: i32) -> crate::Result<()> { self.0.remove_model_at(2, index) } + pub fn move_outline(&self, from_index: i32, to_index: i32) -> crate::Result<()> { self.0.move_model_item(2, from_index, to_index) } + pub fn clear_outline(&self) -> crate::Result<()> { self.0.clear_model_collection(2) } pub fn set_open_file_enabled(&self, enabled: bool) -> crate::Result<()> { self.0.set_command_enabled(1, enabled) } pub fn set_previous_page_enabled(&self, enabled: bool) -> crate::Result<()> { self.0.set_command_enabled(2, enabled) } pub fn set_next_page_enabled(&self, enabled: bool) -> crate::Result<()> { self.0.set_command_enabled(3, enabled) } @@ -61,6 +70,9 @@ impl MainViewModelSink { pub fn set_search_match_label_error(&self, message: Option<&str>) -> crate::Result<()> { self.0.set_property_error(12, message) } pub fn set_can_go_previous_match_error(&self, message: Option<&str>) -> crate::Result<()> { self.0.set_property_error(13, message) } pub fn set_can_go_next_match_error(&self, message: Option<&str>) -> crate::Result<()> { self.0.set_property_error(14, message) } + pub fn set_outline_visible_error(&self, message: Option<&str>) -> crate::Result<()> { self.0.set_property_error(15, message) } + pub fn set_has_outline_error(&self, message: Option<&str>) -> crate::Result<()> { self.0.set_property_error(16, message) } + pub fn set_outline_status_error(&self, message: Option<&str>) -> crate::Result<()> { self.0.set_property_error(17, message) } /// Creates a worker-safe immutable update batch with a monotonic generation. pub fn batch(&self, generation: i64) -> MainViewModelSinkBatch { MainViewModelSinkBatch(crate::view_model::ViewModelBatch::new(generation)) } pub fn submit_batch(&self, batch: MainViewModelSinkBatch) -> crate::Result { self.0.submit_batch(batch.0) } @@ -111,6 +123,15 @@ impl MainViewModelSinkBatch { pub fn set_can_go_next_match(&mut self, value: bool) { self.0.push_boolean(3, 14, value); } pub fn set_can_go_next_match_error(&mut self, message: impl AsRef) { self.0.push_string(18, 14, 0, message); } pub fn clear_can_go_next_match_error(&mut self) { self.0.push_clear_error(14); } + pub fn set_outline_visible(&mut self, value: bool) { self.0.push_boolean(3, 15, value); } + pub fn set_outline_visible_error(&mut self, message: impl AsRef) { self.0.push_string(18, 15, 0, message); } + pub fn clear_outline_visible_error(&mut self) { self.0.push_clear_error(15); } + pub fn set_has_outline(&mut self, value: bool) { self.0.push_boolean(3, 16, value); } + pub fn set_has_outline_error(&mut self, message: impl AsRef) { self.0.push_string(18, 16, 0, message); } + pub fn clear_has_outline_error(&mut self) { self.0.push_clear_error(16); } + pub fn set_outline_status(&mut self, value: impl AsRef) { self.0.push_string(1, 17, 0, value); } + pub fn set_outline_status_error(&mut self, message: impl AsRef) { self.0.push_string(18, 17, 0, message); } + pub fn clear_outline_status_error(&mut self) { self.0.push_clear_error(17); } pub fn add_recent_files(&mut self, value: impl AsRef) { self.0.push_string(7, 1, 0, value); } pub fn insert_recent_files(&mut self, index: i32, value: impl AsRef) { self.0.push_string(9, 1, index, value); } pub fn replace_recent_files(&mut self, index: i32, value: impl AsRef) { self.0.push_string(11, 1, index, value); } @@ -118,6 +139,13 @@ impl MainViewModelSinkBatch { pub fn remove_recent_files(&mut self, index: i32) { self.0.push_indices(13, 1, index, 0); } pub fn move_recent_files(&mut self, from_index: i32, to_index: i32) { self.0.push_indices(14, 1, from_index, to_index); } pub fn clear_recent_files(&mut self) { self.0.push_indices(19, 1, 0, 0); } + pub fn add_outline(&mut self, value: impl OutlineItemViewModel) { self.0.push_model(8, 2, 0, OutlineItemViewModelDispatch { model: value }); } + pub fn insert_outline(&mut self, index: i32, value: impl OutlineItemViewModel) { self.0.push_model(10, 2, index, OutlineItemViewModelDispatch { model: value }); } + pub fn replace_outline(&mut self, index: i32, value: impl OutlineItemViewModel) { self.0.push_model(12, 2, index, OutlineItemViewModelDispatch { model: value }); } + pub fn replace_outline_snapshot(&mut self, values: impl IntoIterator) { self.0.push_model_snapshot(2, values.into_iter().map(|value| OutlineItemViewModelDispatch { model: value })); } + pub fn remove_outline(&mut self, index: i32) { self.0.push_model_indices(13, 2, index, 0); } + pub fn move_outline(&mut self, from_index: i32, to_index: i32) { self.0.push_model_indices(14, 2, from_index, to_index); } + pub fn clear_outline(&mut self) { self.0.push_model_clear(2); } pub fn set_open_file_enabled(&mut self, enabled: bool) { self.0.push_boolean(17, 1, enabled); } pub fn set_previous_page_enabled(&mut self, enabled: bool) { self.0.push_boolean(17, 2, enabled); } pub fn set_next_page_enabled(&mut self, enabled: bool) { self.0.push_boolean(17, 3, enabled); } @@ -134,6 +162,7 @@ pub trait MainViewModel: Send + 'static { fn attach(&mut self, sink: MainViewModelSink) -> crate::Result<()>; fn detach(&mut self) -> crate::Result<()>; fn set_search_text(&mut self, value: String) -> crate::Result<()>; + fn set_outline_visible(&mut self, value: bool) -> crate::Result<()>; fn open_file(&mut self) -> crate::Result<()>; fn previous_page(&mut self) -> crate::Result<()>; fn next_page(&mut self) -> crate::Result<()>; @@ -158,8 +187,11 @@ impl crate::view_model::DynamicViewModel for MainViewModelDisp fn set_integer(&mut self, property_id: i32, _value: i64) -> crate::Result<()> { Err(crate::Error::InvalidViewModelMember { kind: "property", id: property_id }) } - fn set_boolean(&mut self, property_id: i32, _value: bool) -> crate::Result<()> { - Err(crate::Error::InvalidViewModelMember { kind: "property", id: property_id }) + fn set_boolean(&mut self, property_id: i32, value: bool) -> crate::Result<()> { + match property_id { + 15 => self.model.set_outline_visible(value), + _ => Err(crate::Error::InvalidViewModelMember { kind: "property", id: property_id }), + } } fn set_double(&mut self, property_id: i32, _value: f64) -> crate::Result<()> { Err(crate::Error::InvalidViewModelMember { kind: "property", id: property_id }) @@ -185,3 +217,81 @@ impl crate::view_model::DynamicViewModel for MainViewModelDisp } pub fn mount_main_window(scope: &crate::AppScope, model: impl MainViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model(1, MainViewModelDispatch { model }) } + +#[derive(Clone, Debug)] +pub struct OutlineItemViewModelSink(crate::view_model::ViewModelSink); + +impl OutlineItemViewModelSink { + pub fn set_title(&self, value: impl AsRef) -> crate::Result<()> { self.0.set_string(1, value) } + pub fn set_page_label(&self, value: impl AsRef) -> crate::Result<()> { self.0.set_string(2, value) } + pub fn set_has_children(&self, value: bool) -> crate::Result<()> { self.0.set_boolean(3, value) } + pub fn add_children(&self, value: impl OutlineItemViewModel) -> crate::Result<()> { self.0.add_model(1, OutlineItemViewModelDispatch { model: value }) } + pub fn insert_children(&self, index: i32, value: impl OutlineItemViewModel) -> crate::Result<()> { self.0.insert_model(1, index, OutlineItemViewModelDispatch { model: value }) } + pub fn replace_children(&self, index: i32, value: impl OutlineItemViewModel) -> crate::Result<()> { self.0.replace_model(1, index, OutlineItemViewModelDispatch { model: value }) } + pub fn remove_children(&self, index: i32) -> crate::Result<()> { self.0.remove_model_at(1, index) } + pub fn move_children(&self, from_index: i32, to_index: i32) -> crate::Result<()> { self.0.move_model_item(1, from_index, to_index) } + pub fn clear_children(&self) -> crate::Result<()> { self.0.clear_model_collection(1) } + pub fn set_go_to_enabled(&self, enabled: bool) -> crate::Result<()> { self.0.set_command_enabled(1, enabled) } + pub fn set_title_error(&self, message: Option<&str>) -> crate::Result<()> { self.0.set_property_error(1, message) } + pub fn set_page_label_error(&self, message: Option<&str>) -> crate::Result<()> { self.0.set_property_error(2, message) } + pub fn set_has_children_error(&self, message: Option<&str>) -> crate::Result<()> { self.0.set_property_error(3, message) } + /// Creates a worker-safe immutable update batch with a monotonic generation. + pub fn batch(&self, generation: i64) -> OutlineItemViewModelSinkBatch { OutlineItemViewModelSinkBatch(crate::view_model::ViewModelBatch::new(generation)) } + pub fn submit_batch(&self, batch: OutlineItemViewModelSinkBatch) -> crate::Result { self.0.submit_batch(batch.0) } +} + +pub struct OutlineItemViewModelSinkBatch(crate::view_model::ViewModelBatch); + +impl OutlineItemViewModelSinkBatch { + pub fn set_title(&mut self, value: impl AsRef) { self.0.push_string(1, 1, 0, value); } + pub fn set_title_error(&mut self, message: impl AsRef) { self.0.push_string(18, 1, 0, message); } + pub fn clear_title_error(&mut self) { self.0.push_clear_error(1); } + pub fn set_page_label(&mut self, value: impl AsRef) { self.0.push_string(1, 2, 0, value); } + pub fn set_page_label_error(&mut self, message: impl AsRef) { self.0.push_string(18, 2, 0, message); } + pub fn clear_page_label_error(&mut self) { self.0.push_clear_error(2); } + pub fn set_has_children(&mut self, value: bool) { self.0.push_boolean(3, 3, value); } + pub fn set_has_children_error(&mut self, message: impl AsRef) { self.0.push_string(18, 3, 0, message); } + pub fn clear_has_children_error(&mut self) { self.0.push_clear_error(3); } + pub fn add_children(&mut self, value: impl OutlineItemViewModel) { self.0.push_model(8, 1, 0, OutlineItemViewModelDispatch { model: value }); } + pub fn insert_children(&mut self, index: i32, value: impl OutlineItemViewModel) { self.0.push_model(10, 1, index, OutlineItemViewModelDispatch { model: value }); } + pub fn replace_children(&mut self, index: i32, value: impl OutlineItemViewModel) { self.0.push_model(12, 1, index, OutlineItemViewModelDispatch { model: value }); } + pub fn replace_children_snapshot(&mut self, values: impl IntoIterator) { self.0.push_model_snapshot(1, values.into_iter().map(|value| OutlineItemViewModelDispatch { model: value })); } + pub fn remove_children(&mut self, index: i32) { self.0.push_model_indices(13, 1, index, 0); } + pub fn move_children(&mut self, from_index: i32, to_index: i32) { self.0.push_model_indices(14, 1, from_index, to_index); } + pub fn clear_children(&mut self) { self.0.push_model_clear(1); } + pub fn set_go_to_enabled(&mut self, enabled: bool) { self.0.push_boolean(17, 1, enabled); } +} + +pub trait OutlineItemViewModel: Send + 'static { + fn attach(&mut self, sink: OutlineItemViewModelSink) -> crate::Result<()>; + fn detach(&mut self) -> crate::Result<()>; + fn go_to(&mut self) -> crate::Result<()>; +} + +struct OutlineItemViewModelDispatch { model: T } + +impl crate::view_model::DynamicViewModel for OutlineItemViewModelDispatch { + fn attach(&mut self, sink: crate::view_model::ViewModelSink) -> crate::Result<()> { self.model.attach(OutlineItemViewModelSink(sink)) } + fn detach(&mut self) -> crate::Result<()> { self.model.detach() } + fn set_string(&mut self, property_id: i32, _value: String) -> crate::Result<()> { + Err(crate::Error::InvalidViewModelMember { kind: "property", id: property_id }) + } + fn set_integer(&mut self, property_id: i32, _value: i64) -> crate::Result<()> { + Err(crate::Error::InvalidViewModelMember { kind: "property", id: property_id }) + } + fn set_boolean(&mut self, property_id: i32, _value: bool) -> crate::Result<()> { + Err(crate::Error::InvalidViewModelMember { kind: "property", id: property_id }) + } + fn set_double(&mut self, property_id: i32, _value: f64) -> crate::Result<()> { + Err(crate::Error::InvalidViewModelMember { kind: "property", id: property_id }) + } + fn execute(&mut self, command_id: i32, _parameter: Option) -> crate::Result<()> { + match command_id { + 1 => self.model.go_to(), + _ => Err(crate::Error::InvalidViewModelMember { kind: "command", id: command_id }), + } + } + fn begin_async(&mut self, command_id: i32, _parameter: Option) -> crate::Result<()> { + Err(crate::Error::InvalidViewModelMember { kind: "command", id: command_id }) + } +} diff --git a/apps/pdf-viewer/generated/view-model.contract.md b/apps/pdf-viewer/generated/view-model.contract.md index 04b7130..cc2fb05 100644 --- a/apps/pdf-viewer/generated/view-model.contract.md +++ b/apps/pdf-viewer/generated/view-model.contract.md @@ -20,7 +20,11 @@ Schema version: `5` | Property | 12 | `SearchMatchLabel` | `String` | Rust to managed | | Property | 13 | `CanGoPreviousMatch` | `Boolean` | Rust to managed | | Property | 14 | `CanGoNextMatch` | `Boolean` | Rust to managed | +| Property | 15 | `OutlineVisible` | `Boolean` | Rust and managed | +| Property | 16 | `HasOutline` | `Boolean` | Rust to managed | +| Property | 17 | `OutlineStatus` | `String` | Rust to managed | | Collection | 1 | `RecentFiles` | `String` | Rust to managed | +| Collection | 2 | `Outline` | Model `OutlineItemViewModel` (tree root) | Rust to managed | | Async command | 1 | `OpenFile` | None | Managed to Rust | | Command | 2 | `PreviousPage` | None | Managed to Rust | | Command | 3 | `NextPage` | None | Managed to Rust | @@ -30,6 +34,10 @@ Schema version: `5` | Command | 7 | `PreviousMatch` | None | Managed to Rust | | Command | 8 | `NextMatch` | None | Managed to Rust | +### Tree `Outline` + +Node model `OutlineItemViewModel`, children `Children`, header `Title`, has-children `HasChildren`. + ### Recent files `RecentFiles` Storage URIs published into collection `RecentFiles`, capacity 5, activated by `OpenRecentFileCommand` with the chosen URI as its command parameter. @@ -46,6 +54,18 @@ Storage URIs published into collection `RecentFiles`, capacity 5, activated by ` | 6 | `Page` | Submenu | _Page | - | - | - | | 7 |     `PreviousPage` | Command | _Previous page | `PreviousPageCommand` | `Alt+Left` | - | | 8 |     `NextPage` | Command | _Next page | `NextPageCommand` | `Alt+Right` | - | +| 9 | `View` | Submenu | _View | - | - | - | +| 10 |     `Bookmarks` | Toggle | _Bookmarks | - | `Ctrl+B` | `OutlineVisible` | + +## Model `OutlineItemViewModel` (`2`) + +| Kind | ID | Name | Type | Direction | +| --- | ---: | --- | --- | --- | +| Property | 1 | `Title` | `String` | Rust to managed | +| Property | 2 | `PageLabel` | `String` | Rust to managed | +| Property | 3 | `HasChildren` | `Boolean` | Rust to managed | +| Collection | 1 | `Children` | Model `OutlineItemViewModel` (recursive children) | Rust to managed | +| Command | 1 | `GoTo` | None | Managed to Rust | ## Views diff --git a/apps/pdf-viewer/managed/Generated/.avalonia-viewmodel.owned.json b/apps/pdf-viewer/managed/Generated/.avalonia-viewmodel.owned.json index 59999fe..9ebb75d 100644 --- a/apps/pdf-viewer/managed/Generated/.avalonia-viewmodel.owned.json +++ b/apps/pdf-viewer/managed/Generated/.avalonia-viewmodel.owned.json @@ -3,15 +3,23 @@ "files": [ { "path": "MainViewModelAdapter.g.cs", - "sha256": "8f6cd3b672c4b45a1de8dbc868dd548bfd8850fd936fb0900e1872eea1cfed94" + "sha256": "a08c1189a689594e440c9f3ee1a44a22c25a44570edbc8cdb56db95007427422" }, { "path": "MainViewModelMenus.g.cs", - "sha256": "c45507a6ae76117c39a1aec13bcad6c1113dcebc737764ed921ffe1dd9f2d527" + "sha256": "26fdea5a3f6fa4a3b2539c3b4cf809a31b832e14c8672e308a09406aa4515333" }, { "path": "MainViewModelMetadata.g.cs", - "sha256": "8d450647afa77afda44003dcdd5fe3023db0dcf14de6a76725f978f5e3e1b19e" + "sha256": "fd3c61296fe9ac6cf691b001779c69e4a206be642c24b76cc457e9ec02dc418a" + }, + { + "path": "OutlineItemViewModelAdapter.g.cs", + "sha256": "8d925dd9abe563750b161fd653e026bc7d410ad7e2047488ff267a040042428e" + }, + { + "path": "OutlineItemViewModelMetadata.g.cs", + "sha256": "daec627f30a9d80a7c35403a6ef97ea21ab63355b37d2e26ff1a3649b990a337" } ] } diff --git a/apps/pdf-viewer/managed/Generated/MainViewModelAdapter.g.cs b/apps/pdf-viewer/managed/Generated/MainViewModelAdapter.g.cs index dbf9360..f3e12a3 100644 --- a/apps/pdf-viewer/managed/Generated/MainViewModelAdapter.g.cs +++ b/apps/pdf-viewer/managed/Generated/MainViewModelAdapter.g.cs @@ -40,6 +40,9 @@ public sealed partial class MainViewModelAdapter : IAvnRustVmSink, IAvnRustVmSin private string _searchMatchLabel = ""; private bool _canGoPreviousMatch = false; private bool _canGoNextMatch = false; + private bool _outlineVisible = true; + private bool _hasOutline = false; + private string _outlineStatus = "No bookmarks"; /// Creates an adapter that dispatches and posts through . public MainViewModelAdapter(IAvnRustViewModel model) : this(model, null, null) { } @@ -183,7 +186,50 @@ public bool CanGoNextMatch get => _canGoNextMatch; } + public bool OutlineVisible + { + get => _outlineVisible; + set + { + var accepted = value; + if (Equals(_outlineVisible, accepted)) + return; + var previous = _outlineVisible; + var inbound = _inboundWrites.Begin(15); + try + { + Check(_model.SetBoolean(15, (accepted ? 1 : 0))); + if (!_inboundWrites.WasPublished(inbound)) + { + _inboundWrites.CommitLocal(15); + SetField(ref _outlineVisible, accepted, nameof(OutlineVisible)); + } + } + catch + { + if (_inboundWrites.ShouldRollback(inbound)) + { + _inboundWrites.CommitLocal(15); + SetField(ref _outlineVisible, previous, nameof(OutlineVisible)); + } + throw; + } + finally { _inboundWrites.End(inbound); } + } + } + + public bool HasOutline + { + get => _hasOutline; + } + + public string OutlineStatus + { + get => _outlineStatus; + } + public BatchObservableCollection RecentFiles { get; } = []; + public BatchObservableCollection Outline { get; } = []; public DelegateCommand OpenFileCommand { get; } public DelegateCommand PreviousPageCommand { get; } @@ -215,6 +261,7 @@ public int SetString(int propertyId, string? value) 10 => Apply(() => { var converted = value ?? ""; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_searchText, converted)) SetField(ref _searchText, converted, nameof(SearchText)); }), 11 => Apply(() => { var converted = value ?? ""; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_searchStatus, converted)) SetField(ref _searchStatus, converted, nameof(SearchStatus)); }), 12 => Apply(() => { var converted = value ?? ""; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_searchMatchLabel, converted)) SetField(ref _searchMatchLabel, converted, nameof(SearchMatchLabel)); }), + 17 => Apply(() => { var converted = value ?? ""; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_outlineStatus, converted)) SetField(ref _outlineStatus, converted, nameof(OutlineStatus)); }), _ => unchecked((int)0x80070057), }; } @@ -238,6 +285,8 @@ public int SetBoolean(int propertyId, int value) 9 => Apply(() => { var converted = value != 0; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_isLoading, converted)) SetField(ref _isLoading, converted, nameof(IsLoading)); }), 13 => Apply(() => { var converted = value != 0; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_canGoPreviousMatch, converted)) SetField(ref _canGoPreviousMatch, converted, nameof(CanGoPreviousMatch)); }), 14 => Apply(() => { var converted = value != 0; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_canGoNextMatch, converted)) SetField(ref _canGoNextMatch, converted, nameof(CanGoNextMatch)); }), + 15 => Apply(() => { var converted = value != 0; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_outlineVisible, converted)) SetField(ref _outlineVisible, converted, nameof(OutlineVisible)); }), + 16 => Apply(() => { var converted = value != 0; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_hasOutline, converted)) SetField(ref _hasOutline, converted, nameof(HasOutline)); }), _ => unchecked((int)0x80070057), }; } @@ -267,6 +316,7 @@ public int SetNull(int propertyId) public int AddModel(int collectionId, IAvnRustViewModel? model) => collectionId switch { + 2 => model is null ? unchecked((int)0x80070057) : Apply(() => Outline.Add(new global::PdfViewer.Presentation.Generated.OutlineItemViewModelAdapter(model, _dispatch, _post))), _ => unchecked((int)0x80070057), }; @@ -278,6 +328,7 @@ public int SetNull(int propertyId) public int InsertModel(int collectionId, int index, IAvnRustViewModel? model) => collectionId switch { + 2 => model is null ? unchecked((int)0x80070057) : Apply(() => { if ((uint)index > (uint)Outline.Count) return unchecked((int)0x80070057); Outline.Insert(index, new global::PdfViewer.Presentation.Generated.OutlineItemViewModelAdapter(model, _dispatch, _post)); return 0; }), _ => unchecked((int)0x80070057), }; @@ -289,24 +340,46 @@ public int SetNull(int propertyId) public int ReplaceModel(int collectionId, int index, IAvnRustViewModel? model) => collectionId switch { + 2 => model is null ? unchecked((int)0x80070057) : Apply(() => + { + if ((uint)index >= (uint)Outline.Count) return unchecked((int)0x80070057); + var previous = Outline[index]; + Outline[index] = new global::PdfViewer.Presentation.Generated.OutlineItemViewModelAdapter(model, _dispatch, _post); + previous.Dispose(); + return 0; + }), _ => unchecked((int)0x80070057), }; public int RemoveAt(int collectionId, int index) => collectionId switch { 1 => Apply(() => { if ((uint)index >= (uint)RecentFiles.Count) return unchecked((int)0x80070057); RecentFiles.RemoveAt(index); return 0; }), + 2 => Apply(() => + { + if ((uint)index >= (uint)Outline.Count) return unchecked((int)0x80070057); + var item = Outline[index]; + Outline.RemoveAt(index); + item.Dispose(); + return 0; + }), _ => unchecked((int)0x80070057), }; public int MoveItem(int collectionId, int fromIndex, int toIndex) => collectionId switch { 1 => Apply(() => { if ((uint)fromIndex >= (uint)RecentFiles.Count || (uint)toIndex >= (uint)RecentFiles.Count) return unchecked((int)0x80070057); RecentFiles.Move(fromIndex, toIndex); return 0; }), + 2 => Apply(() => { if ((uint)fromIndex >= (uint)Outline.Count || (uint)toIndex >= (uint)Outline.Count) return unchecked((int)0x80070057); Outline.Move(fromIndex, toIndex); return 0; }), _ => unchecked((int)0x80070057), }; public int ClearCollection(int collectionId) => collectionId switch { 1 => Apply(RecentFiles.Clear), + 2 => Apply(() => + { + foreach (var item in Outline) item.Dispose(); + Outline.Clear(); + }), _ => unchecked((int)0x80070057), }; @@ -339,6 +412,9 @@ public int SetNull(int propertyId) 12 => Apply(() => SetError(nameof(SearchMatchLabel), message)), 13 => Apply(() => SetError(nameof(CanGoPreviousMatch), message)), 14 => Apply(() => SetError(nameof(CanGoNextMatch), message)), + 15 => Apply(() => SetError(nameof(OutlineVisible), message)), + 16 => Apply(() => SetError(nameof(HasOutline), message)), + 17 => Apply(() => SetError(nameof(OutlineStatus), message)), _ => unchecked((int)0x80070057), }; @@ -356,6 +432,15 @@ public int SetNull(int propertyId) public int ReplaceModelSnapshot(int collectionId, IReadOnlyList values) => collectionId switch { + 2 => Apply(() => + { + var staged = new List(); + try { foreach (var value in values) staged.Add(new global::PdfViewer.Presentation.Generated.OutlineItemViewModelAdapter(value, _dispatch, _post)); } + catch { foreach (var value in staged) TryDispose(value); throw; } + var previous = Outline.ToArray(); + Outline.ReplaceSnapshot(staged); + foreach (var value in previous) TryDispose(value); + }), _ => unchecked((int)0x80070057), }; @@ -377,6 +462,9 @@ bool IRustVmBatchTarget.TryGetProperty(int propertyId, out RustVmBatchProperty p 12 => new RustVmBatchProperty(nameof(SearchMatchLabel), RustVmValueWireKind.String, false, false), 13 => new RustVmBatchProperty(nameof(CanGoPreviousMatch), RustVmValueWireKind.Boolean, false, false), 14 => new RustVmBatchProperty(nameof(CanGoNextMatch), RustVmValueWireKind.Boolean, false, false), + 15 => new RustVmBatchProperty(nameof(OutlineVisible), RustVmValueWireKind.Boolean, false, false), + 16 => new RustVmBatchProperty(nameof(HasOutline), RustVmValueWireKind.Boolean, false, false), + 17 => new RustVmBatchProperty(nameof(OutlineStatus), RustVmValueWireKind.String, false, false), _ => default, }; return property.Name is not null; @@ -387,6 +475,7 @@ bool IRustVmBatchTarget.TryGetCollection(int collectionId, out RustVmBatchCollec collection = collectionId switch { 1 => new RustVmBatchCollectionInfo(nameof(RecentFiles), RustVmValueWireKind.String, RecentFiles), + 2 => new RustVmBatchCollectionInfo(nameof(Outline), RustVmValueWireKind.Model, Outline), _ => default, }; return collection.Items is not null; @@ -421,6 +510,7 @@ bool IRustVmBatchTarget.TryGetCommand(int commandId, out IRustVmBatchCommand com IDisposable IRustVmBatchTarget.CreateNestedElement(int collectionId, IAvnRustViewModel model) => collectionId switch { + 2 => new global::PdfViewer.Presentation.Generated.OutlineItemViewModelAdapter(model, _dispatch, _post), _ => throw new ArgumentOutOfRangeException(nameof(collectionId)), }; @@ -527,6 +617,27 @@ bool IRustVmBatchTarget.CommitProperty(int propertyId, in RustVmBatchValue value _canGoNextMatch = next; return true; } + case 15: + { + var next = value.Boolean; + if (Equals(_outlineVisible, next)) return false; + _outlineVisible = next; + return true; + } + case 16: + { + var next = value.Boolean; + if (Equals(_hasOutline, next)) return false; + _hasOutline = next; + return true; + } + case 17: + { + var next = value.Text ?? ""; + if (Equals(_outlineStatus, next)) return false; + _outlineStatus = next; + return true; + } default: return false; } } @@ -572,6 +683,7 @@ private void DisposeCore() private void DisposeNestedAdapters() { + foreach (var item in Outline) TryDispose(item); } private static void TryDispose(IDisposable? value) diff --git a/apps/pdf-viewer/managed/Generated/MainViewModelMenus.g.cs b/apps/pdf-viewer/managed/Generated/MainViewModelMenus.g.cs index 514a7c0..cfdf114 100644 --- a/apps/pdf-viewer/managed/Generated/MainViewModelMenus.g.cs +++ b/apps/pdf-viewer/managed/Generated/MainViewModelMenus.g.cs @@ -68,6 +68,17 @@ public static NativeMenu CreateMain(MainViewModelAdapter model, RustMenuScope sc pageMenu.Items.Add(nextPageItem); keyBindings?.Add(new KeyBinding { Gesture = RustMenu.ParseGesture("Alt+Right")!, Command = nextPageCommand }); menu.Items.Add(pageItem); + var viewItem = new NativeMenuItem("_View"); + var viewMenu = new NativeMenu(); + viewItem.Menu = viewMenu; + var bookmarksCommand = new RustMenuCommand(parameter => { model.OutlineVisible = !model.OutlineVisible; }, null); + var bookmarksItem = new NativeMenuItem("_Bookmarks") { Command = bookmarksCommand }; + bookmarksItem.ToggleType = MenuItemToggleType.CheckBox; + bookmarksItem.Gesture = RustMenu.ParseGesture("Ctrl+B"); + scope.Observe("OutlineVisible", () => bookmarksItem.IsChecked = model.OutlineVisible); + viewMenu.Items.Add(bookmarksItem); + keyBindings?.Add(new KeyBinding { Gesture = RustMenu.ParseGesture("Ctrl+B")!, Command = bookmarksCommand }); + menu.Items.Add(viewItem); return menu; } diff --git a/apps/pdf-viewer/managed/Generated/MainViewModelMetadata.g.cs b/apps/pdf-viewer/managed/Generated/MainViewModelMetadata.g.cs index e0bea5c..be6fecc 100644 --- a/apps/pdf-viewer/managed/Generated/MainViewModelMetadata.g.cs +++ b/apps/pdf-viewer/managed/Generated/MainViewModelMetadata.g.cs @@ -25,9 +25,13 @@ public static class MainViewModelMetadata new(12, "SearchMatchLabel", RustViewModelValueKind.String, false, false, "", null), new(13, "CanGoPreviousMatch", RustViewModelValueKind.Boolean, false, false, false, null), new(14, "CanGoNextMatch", RustViewModelValueKind.Boolean, false, false, false, null), + new(15, "OutlineVisible", RustViewModelValueKind.Boolean, true, false, true, null), + new(16, "HasOutline", RustViewModelValueKind.Boolean, false, false, false, null), + new(17, "OutlineStatus", RustViewModelValueKind.String, false, false, "No bookmarks", null), ], [ new(1, "RecentFiles", RustViewModelValueKind.String, null, null, null, null, false), + new(2, "Outline", RustViewModelValueKind.Model, global::PdfViewer.Presentation.Generated.OutlineItemViewModelMetadata.Descriptor, null, null, new("Children", "Title", "HasChildren"), false), ], [ new(1, "OpenFileCommand", true, null, false, null, false, false), diff --git a/apps/pdf-viewer/managed/Generated/OutlineItemViewModelAdapter.g.cs b/apps/pdf-viewer/managed/Generated/OutlineItemViewModelAdapter.g.cs new file mode 100644 index 0000000..e95fc37 --- /dev/null +++ b/apps/pdf-viewer/managed/Generated/OutlineItemViewModelAdapter.g.cs @@ -0,0 +1,451 @@ +// +#nullable enable +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using System.Windows.Input; +using Avalonia.Rust; +using Avalonia.Rust.Interop; +using Avalonia.Threading; + +namespace PdfViewer.Presentation.Generated; + +[GeneratedComClass] +public sealed partial class OutlineItemViewModelAdapter : IAvnRustVmSink, IAvnRustVmSink2, IAvnRustVmSink3, IRustVmStringSnapshotSink, IRustVmModelSnapshotSink, IRustVmBatchTarget, IRustVmTableSelectionBatchTarget, INotifyPropertyChanged, INotifyDataErrorInfo, IDisposable +{ + private readonly IAvnRustViewModel _model; + private readonly Action _dispatch; + private readonly Action? _post; + private readonly RustVmBatchCoordinator _batch; + private readonly Dictionary _errors = new(StringComparer.Ordinal); + private readonly RustVmInboundWriteTracker _inboundWrites = new(); + private string _title = ""; + private string _pageLabel = ""; + private bool _hasChildren = false; + + /// Creates an adapter that dispatches and posts through . + public OutlineItemViewModelAdapter(IAvnRustViewModel model) : this(model, null, null) { } + + /// + /// Creates an adapter with a custom synchronous dispatch for the legacy v1/v2 + /// sink path. Kept as a distinct CLR signature (not an optional parameter) so + /// already-compiled callers keep binding to it. + /// + public OutlineItemViewModelAdapter(IAvnRustViewModel model, Action? dispatch) : this(model, dispatch, null) { } + + /// + /// Creates an adapter with a custom synchronous for the + /// legacy v1/v2 sink path and a custom nonblocking for + /// batch submission. + /// + public OutlineItemViewModelAdapter(IAvnRustViewModel model, Action? dispatch, Action? post) + { + _model = model; + _dispatch = dispatch ?? Dispatch; + _post = post; + _batch = new RustVmBatchCoordinator(this, post); + GoToCommand = new DelegateCommand(parameter => Check(_model.Execute(1, null))); + try + { + Check(_model.Attach(this)); + } + catch + { + try { _model.Detach(); } + catch { } + DisposeNestedAdapters(); + throw; + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + public event EventHandler? ErrorsChanged; + + public string Title + { + get => _title; + } + + public string PageLabel + { + get => _pageLabel; + } + + public bool HasChildren + { + get => _hasChildren; + } + + public BatchObservableCollection Children { get; } = []; + + public DelegateCommand GoToCommand { get; } + + public bool HasErrors => _errors.Count > 0; + + public IEnumerable GetErrors(string? propertyName) => + propertyName is not null && _errors.TryGetValue(propertyName, out var message) + ? new[] { message } + : Array.Empty(); + + public int SetString(int propertyId, string? value) + { + var inbound = _inboundWrites.MarkPublication(propertyId); + return propertyId switch + { + 1 => Apply(() => { var converted = value ?? ""; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_title, converted)) SetField(ref _title, converted, nameof(Title)); }), + 2 => Apply(() => { var converted = value ?? ""; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_pageLabel, converted)) SetField(ref _pageLabel, converted, nameof(PageLabel)); }), + _ => unchecked((int)0x80070057), + }; + } + + public int SetInteger(int propertyId, long value) + { + var inbound = _inboundWrites.MarkPublication(propertyId); + return propertyId switch + { + _ => unchecked((int)0x80070057), + }; + } + + public int SetBoolean(int propertyId, int value) + { + var inbound = _inboundWrites.MarkPublication(propertyId); + return propertyId switch + { + 3 => Apply(() => { var converted = value != 0; _inboundWrites.CommitPublication(propertyId, inbound); if (!Equals(_hasChildren, converted)) SetField(ref _hasChildren, converted, nameof(HasChildren)); }), + _ => unchecked((int)0x80070057), + }; + } + + public int SetDouble(int propertyId, double value) + { + var inbound = _inboundWrites.MarkPublication(propertyId); + return propertyId switch + { + _ => unchecked((int)0x80070057), + }; + } + + public int SetNull(int propertyId) + { + var inbound = _inboundWrites.MarkPublication(propertyId); + return propertyId switch + { + _ => unchecked((int)0x80070057), + }; + } + + public int SetModel(int propertyId, IAvnRustViewModel? model) => propertyId switch + { + _ => unchecked((int)0x80070057), + }; + + public int AddModel(int collectionId, IAvnRustViewModel? model) => collectionId switch + { + 1 => model is null ? unchecked((int)0x80070057) : Apply(() => Children.Add(new global::PdfViewer.Presentation.Generated.OutlineItemViewModelAdapter(model, _dispatch, _post))), + _ => unchecked((int)0x80070057), + }; + + public int InsertString(int collectionId, int index, string? value) => collectionId switch + { + _ => unchecked((int)0x80070057), + }; + + public int InsertModel(int collectionId, int index, IAvnRustViewModel? model) => collectionId switch + { + 1 => model is null ? unchecked((int)0x80070057) : Apply(() => { if ((uint)index > (uint)Children.Count) return unchecked((int)0x80070057); Children.Insert(index, new global::PdfViewer.Presentation.Generated.OutlineItemViewModelAdapter(model, _dispatch, _post)); return 0; }), + _ => unchecked((int)0x80070057), + }; + + public int ReplaceString(int collectionId, int index, string? value) => collectionId switch + { + _ => unchecked((int)0x80070057), + }; + + public int ReplaceModel(int collectionId, int index, IAvnRustViewModel? model) => collectionId switch + { + 1 => model is null ? unchecked((int)0x80070057) : Apply(() => + { + if ((uint)index >= (uint)Children.Count) return unchecked((int)0x80070057); + var previous = Children[index]; + Children[index] = new global::PdfViewer.Presentation.Generated.OutlineItemViewModelAdapter(model, _dispatch, _post); + previous.Dispose(); + return 0; + }), + _ => unchecked((int)0x80070057), + }; + + public int RemoveAt(int collectionId, int index) => collectionId switch + { + 1 => Apply(() => + { + if ((uint)index >= (uint)Children.Count) return unchecked((int)0x80070057); + var item = Children[index]; + Children.RemoveAt(index); + item.Dispose(); + return 0; + }), + _ => unchecked((int)0x80070057), + }; + + public int MoveItem(int collectionId, int fromIndex, int toIndex) => collectionId switch + { + 1 => Apply(() => { if ((uint)fromIndex >= (uint)Children.Count || (uint)toIndex >= (uint)Children.Count) return unchecked((int)0x80070057); Children.Move(fromIndex, toIndex); return 0; }), + _ => unchecked((int)0x80070057), + }; + + public int ClearCollection(int collectionId) => collectionId switch + { + 1 => Apply(() => + { + foreach (var item in Children) item.Dispose(); + Children.Clear(); + }), + _ => unchecked((int)0x80070057), + }; + + public int SetCommandEnabled(int commandId, int enabled) => commandId switch + { + 1 => Apply(() => GoToCommand.SetEnabled(enabled != 0)), + _ => unchecked((int)0x80070057), + }; + + public int SetPropertyError(int propertyId, string? message) => propertyId switch + { + 1 => Apply(() => SetError(nameof(Title), message)), + 2 => Apply(() => SetError(nameof(PageLabel), message)), + 3 => Apply(() => SetError(nameof(HasChildren), message)), + _ => unchecked((int)0x80070057), + }; + + public int AddString(int collectionId, string? value) => collectionId switch + { + _ => unchecked((int)0x80070057), + }; + + public int ReplaceStringSnapshot(int collectionId, IReadOnlyList values) => collectionId switch + { + _ => unchecked((int)0x80070057), + }; + + public int ReplaceModelSnapshot(int collectionId, IReadOnlyList values) => collectionId switch + { + 1 => Apply(() => + { + var staged = new List(); + try { foreach (var value in values) staged.Add(new global::PdfViewer.Presentation.Generated.OutlineItemViewModelAdapter(value, _dispatch, _post)); } + catch { foreach (var value in staged) TryDispose(value); throw; } + var previous = Children.ToArray(); + Children.ReplaceSnapshot(staged); + foreach (var value in previous) TryDispose(value); + }), + _ => unchecked((int)0x80070057), + }; + + bool IRustVmBatchTarget.TryGetProperty(int propertyId, out RustVmBatchProperty property) + { + property = propertyId switch + { + 1 => new RustVmBatchProperty(nameof(Title), RustVmValueWireKind.String, false, false), + 2 => new RustVmBatchProperty(nameof(PageLabel), RustVmValueWireKind.String, false, false), + 3 => new RustVmBatchProperty(nameof(HasChildren), RustVmValueWireKind.Boolean, false, false), + _ => default, + }; + return property.Name is not null; + } + + bool IRustVmBatchTarget.TryGetCollection(int collectionId, out RustVmBatchCollectionInfo collection) + { + collection = collectionId switch + { + 1 => new RustVmBatchCollectionInfo(nameof(Children), RustVmValueWireKind.Model, Children), + _ => default, + }; + return collection.Items is not null; + } + + bool IRustVmBatchTarget.TryGetCommand(int commandId, out IRustVmBatchCommand command) + { + command = commandId switch + { + 1 => GoToCommand, + _ => null!, + }; + return command is not null; + } + + bool IRustVmBatchTarget.IsEnumValueDefined(int propertyId, long value) => propertyId switch + { + _ => false, + }; + + IDisposable IRustVmBatchTarget.CreateNestedProperty(int propertyId, IAvnRustViewModel model) => propertyId switch + { + _ => throw new ArgumentOutOfRangeException(nameof(propertyId)), + }; + + IDisposable IRustVmBatchTarget.CreateNestedElement(int collectionId, IAvnRustViewModel model) => collectionId switch + { + 1 => new global::PdfViewer.Presentation.Generated.OutlineItemViewModelAdapter(model, _dispatch, _post), + _ => throw new ArgumentOutOfRangeException(nameof(collectionId)), + }; + + bool IRustVmBatchTarget.CommitProperty(int propertyId, in RustVmBatchValue value, out IDisposable? replaced) + { + replaced = null; + switch (propertyId) + { + case 1: + { + var next = value.Text ?? ""; + if (Equals(_title, next)) return false; + _title = next; + return true; + } + case 2: + { + var next = value.Text ?? ""; + if (Equals(_pageLabel, next)) return false; + _pageLabel = next; + return true; + } + case 3: + { + var next = value.Boolean; + if (Equals(_hasChildren, next)) return false; + _hasChildren = next; + return true; + } + default: return false; + } + } + + bool IRustVmBatchTarget.CommitError(string propertyName, string? message) => + RustVmBatchErrors.Set(_errors, propertyName, message); + + bool IRustVmTableSelectionBatchTarget.IsPostCollectionPropertyNotification(string propertyName, IReadOnlySet changedCollections) => propertyName switch + { + _ => false, + }; + + void IRustVmBatchTarget.RaisePropertyChanged(string propertyName) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + void IRustVmBatchTarget.RaiseErrorsChanged(string propertyName) => + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); + + /// + /// Enqueues one immutable batch. This call never reads the batch, applies it, + /// or completes it on the submitting (Rust worker) stack. + /// + public int SubmitBatch(IAvnRustVmUpdateBatch? batch) => _batch.Submit(batch); + + /// + /// Detaches the model and disposes every nested adapter exactly once. When a + /// batch notification triggers this re-entrantly, the gate defers the cleanup + /// until the batch's commit and notifications have finished. + /// + public void Dispose() => _batch.Dispose(DisposeCore); + + private void DisposeCore() + { + try + { + Check(_model.Detach()); + } + finally + { + DisposeNestedAdapters(); + } + } + + private void DisposeNestedAdapters() + { + foreach (var item in Children) TryDispose(item); + } + + private static void TryDispose(IDisposable? value) + { + try { value?.Dispose(); } + catch { } + } + + private int Apply(Action action) => Apply(() => + { + action(); + return 0; + }); + + private int Apply(Func action) + { + if (_batch.IsClosed) return 0; + var hresult = 0; + void ApplyIfAlive() + { + if (!_batch.IsClosed) + { + try { hresult = action(); } + catch { hresult = unchecked((int)0x80004005); } + } + } + try { _dispatch(ApplyIfAlive); } + catch { return unchecked((int)0x80004005); } + return hresult; + } + + private static void Dispatch(Action action) + { + if (Dispatcher.UIThread.CheckAccess()) action(); + else Dispatcher.UIThread.Invoke(action); + } + + private static void Check(int hresult) + { + if (hresult < 0) Marshal.ThrowExceptionForHR(hresult); + } + + private void SetField(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (Equals(field, value)) return; + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private void SetError(string propertyName, string? message) + { + if (RustVmBatchErrors.Set(_errors, propertyName, message)) + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); + } + + public sealed class DelegateCommand(Action execute) : ICommand, IRustVmBatchCommand + { + private bool _canExecute = true; + + public DelegateCommand(Action execute) : this(_ => execute()) { } + + public event EventHandler? CanExecuteChanged; + public bool CanExecute(object? parameter) => _canExecute; + public void Execute(object? parameter) => execute(parameter); + + public void SetEnabled(bool value) + { + if (SetEnabledCore(value)) RaiseCanExecuteChanged(); + } + + public bool SetEnabledCore(bool enabled) + { + if (_canExecute == enabled) return false; + _canExecute = enabled; + return true; + } + + public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/apps/pdf-viewer/managed/Generated/OutlineItemViewModelMetadata.g.cs b/apps/pdf-viewer/managed/Generated/OutlineItemViewModelMetadata.g.cs new file mode 100644 index 0000000..27eba7d --- /dev/null +++ b/apps/pdf-viewer/managed/Generated/OutlineItemViewModelMetadata.g.cs @@ -0,0 +1,26 @@ +// +#nullable enable +using System.Collections.Generic; +using Avalonia.Rust; + +namespace PdfViewer.Presentation.Generated; + +public static class OutlineItemViewModelMetadata +{ + public static RustViewModelDescriptor Descriptor { get; } = new( + 2, + "OutlineItemViewModel", + [ + new(1, "Title", RustViewModelValueKind.String, false, false, "", null), + new(2, "PageLabel", RustViewModelValueKind.String, false, false, "", null), + new(3, "HasChildren", RustViewModelValueKind.Boolean, false, false, false, null), + ], + [ + new(1, "Children", RustViewModelValueKind.Model, null, null, null, null, true), + ], + [ + new(1, "GoToCommand", false, null, false, null, false, false), + ], + [ + ]); +} diff --git a/apps/pdf-viewer/managed/Views/MainWindow.axaml b/apps/pdf-viewer/managed/Views/MainWindow.axaml index d96e024..0fe37de 100644 --- a/apps/pdf-viewer/managed/Views/MainWindow.axaml +++ b/apps/pdf-viewer/managed/Views/MainWindow.axaml @@ -4,9 +4,9 @@ xmlns:local="using:PdfViewer.Presentation" x:Class="PdfViewer.Presentation.Views.MainWindow" x:DataType="vm:MainViewModelAdapter" - Width="1280" + Width="1520" Height="860" - MinWidth="980" + MinWidth="1180" MinHeight="660" Title="{Binding Title}" Background="{StaticResource WindowBrush}" @@ -113,6 +113,52 @@ + + + + + + + +