From 7593355bce81faa817699a225cb55f2080dc06ee Mon Sep 17 00:00:00 2001 From: Awetspoon Date: Fri, 7 Aug 2026 22:59:38 +0100 Subject: [PATCH] release: prepare v2.5.0 --- .editorconfig | 6 + .github/workflows/windows-build.yml | 48 + CHANGELOG.md | 29 + Directory.Build.props | 7 + README.md | 85 +- RELEASE_CHECKLIST.md | 25 +- docs/REPOSITORY_STRUCTURE.md | 25 +- docs/UI_QA_CHECKLIST.md | 20 +- docs/columnpad-screenshot.png | Bin 33704 -> 30009 bytes docs/releases/v2.4.0.md | 2 +- docs/releases/v2.5.0.md | 42 + .../ColumnPadStudio-program-map.workflow.json | 10 +- global.json | 7 + .../ColumnPadStudio.Domain.csproj | 4 +- .../Workspaces/WorkspaceColumnLayout.cs | 48 + .../Workspaces/WorkspaceConstraints.cs | 14 + .../Workspaces/WorkspaceImportRules.cs | 188 ++-- src/ColumnPadStudio/App.xaml.cs | 39 +- src/ColumnPadStudio/ColumnPadStudio.csproj | 14 +- .../Controls/ColumnEditorControl.Gutter.cs | 121 ++- .../Controls/ColumnEditorControl.Images.cs | 27 +- .../Controls/ColumnEditorControl.Lifecycle.cs | 126 ++- .../Controls/ColumnEditorControl.Menus.cs | 66 +- .../Controls/ColumnEditorControl.xaml | 299 ++++-- .../Controls/ColumnEditorControl.xaml.cs | 18 +- .../Controls/PaperBackground.cs | 112 +++ .../Controls/WorkflowBuilderWindow.Canvas.cs | 31 +- .../WorkflowBuilderWindow.FileActions.cs | 19 +- .../Controls/WorkflowBuilderWindow.xaml | 122 ++- .../MainWindow.ColumnFormatting.cs | 140 +++ src/ColumnPadStudio/MainWindow.ColumnHost.cs | 322 ++++++- .../MainWindow.DestructiveActions.cs | 12 +- .../MainWindow.EditorSurface.cs | 198 ++-- src/ColumnPadStudio/MainWindow.FileSession.cs | 34 +- src/ColumnPadStudio/MainWindow.Images.cs | 8 +- src/ColumnPadStudio/MainWindow.Lifecycle.cs | 232 ++++- .../MainWindow.SaveBeforeExit.cs | 16 +- src/ColumnPadStudio/MainWindow.Shortcuts.cs | 2 +- src/ColumnPadStudio/MainWindow.ViewModes.cs | 7 +- .../MainWindow.WorkspaceSessions.cs | 7 + src/ColumnPadStudio/MainWindow.Workspaces.cs | 15 + src/ColumnPadStudio/MainWindow.xaml | 86 +- src/ColumnPadStudio/MainWindow.xaml.cs | 70 +- src/ColumnPadStudio/Models/AppPreferences.cs | 25 +- src/ColumnPadStudio/Models/PaperStyle.cs | 8 + src/ColumnPadStudio/Models/SaveFileKind.cs | 3 +- .../PublishProfiles/FolderProfile.pubxml | 5 +- .../Resources/AppResources.xaml | 3 +- .../Resources/ControlStyles.xaml | 18 +- .../Resources/Themes/DarkTheme.xaml | 61 ++ .../Resources/Themes/DefaultTheme.xaml | 61 ++ .../LightTheme.xaml} | 48 +- .../Services/AppPreferencesService.cs | 93 +- .../Services/AppStoragePaths.cs | 2 - .../Services/ClipboardTextService.cs | 79 +- .../Services/ColumnImageFileService.cs | 129 ++- .../Services/ColumnTextColorService.cs | 83 ++ .../Services/FileWorkflowService.cs | 41 +- .../Services/LatestWriteCoordinator.cs | 190 ++++ .../Services/TextSearchService.cs | 3 +- .../Services/ThemeResourceService.cs | 247 +---- .../Services/WorkflowService.Migrations.cs | 178 ++++ ...WorkflowService.ReadableMarkdownExports.cs | 144 --- .../WorkflowService.ReadableTextExports.cs | 47 +- .../Services/WorkflowService.Serialization.cs | 33 +- .../Services/WorkflowService.cs | 11 +- .../Services/WorkspaceRecoveryStore.cs | 363 ++++++-- .../Services/WorkspaceSessionFileService.cs | 28 +- .../ViewModels/ColumnImageViewModel.cs | 71 +- .../ViewModels/ColumnViewModel.Checklists.cs | 14 +- .../ViewModels/ColumnViewModel.cs | 102 ++- .../ViewModels/MainViewModel.Columns.cs | 31 +- .../ViewModels/MainViewModel.FileState.cs | 42 +- .../ViewModels/MainViewModel.Fonts.cs | 16 + .../ViewModels/MainViewModel.JsonHelpers.cs | 35 +- .../MainViewModel.LayoutMigration.cs | 109 +-- .../MainViewModel.LayoutPersistence.cs | 158 +++- .../ViewModels/MainViewModel.LayoutSchema.cs | 47 +- .../ViewModels/MainViewModel.Persistence.cs | 5 +- .../ViewModels/MainViewModel.TextDocuments.cs | 52 +- .../ViewModels/MainViewModel.cs | 122 ++- ...orkflowBuilderViewModel.ConnectionDraft.cs | 120 +++ .../WorkflowBuilderViewModel.Library.cs | 14 +- .../WorkflowBuilderViewModel.NodeActions.cs | 130 ++- .../WorkflowBuilderViewModel.Preview.cs | 5 + .../ViewModels/WorkflowBuilderViewModel.cs | 5 +- .../ViewModels/WorkspaceSession.cs | 52 +- .../Workflows/WorkflowDefaults.cs | 1 - .../Workflows/WorkflowDefinition.cs | 10 +- .../Workflows/WorkflowEnums.cs | 8 - .../WorkflowTemplateCatalog.Builders.cs | 4 - .../Workflows/WorkflowTemplateCatalog.cs | 12 - .../Workflows/WorkflowTemplateDefinition.cs | 2 - .../WorkspaceColumnEditorCache.cs | 107 +++ .../ColumnPadStudio.Domain.Tests.csproj | 4 +- tests/ColumnPadStudio.Domain.Tests/Program.cs | 64 +- .../AutoRecoverySmokeTests.cs | 250 +++++ .../ColumnPadStudio.SmokeTests.csproj | 4 +- .../EditorServiceSmokeTests.cs | 69 ++ .../ImageSafetySmokeTests.cs | 45 + .../InfrastructureSmokeTests.cs | 278 ++++++ tests/ColumnPadStudio.SmokeTests/Program.cs | 789 ++++++++-------- .../SmokeTestContext.cs | 30 + .../ThemeAndControlSmokeTests.cs | 861 ++++++++++++++++++ .../WorkflowSmokeTests.cs | 288 ++++++ 105 files changed, 6546 insertions(+), 2016 deletions(-) create mode 100644 .github/workflows/windows-build.yml create mode 100644 Directory.Build.props create mode 100644 docs/releases/v2.5.0.md create mode 100644 global.json create mode 100644 src/ColumnPadStudio.Domain/Workspaces/WorkspaceColumnLayout.cs create mode 100644 src/ColumnPadStudio/Controls/PaperBackground.cs create mode 100644 src/ColumnPadStudio/MainWindow.ColumnFormatting.cs create mode 100644 src/ColumnPadStudio/Models/PaperStyle.cs create mode 100644 src/ColumnPadStudio/Resources/Themes/DarkTheme.xaml create mode 100644 src/ColumnPadStudio/Resources/Themes/DefaultTheme.xaml rename src/ColumnPadStudio/Resources/{ThemeBrushes.xaml => Themes/LightTheme.xaml} (72%) create mode 100644 src/ColumnPadStudio/Services/ColumnTextColorService.cs create mode 100644 src/ColumnPadStudio/Services/LatestWriteCoordinator.cs create mode 100644 src/ColumnPadStudio/Services/WorkflowService.Migrations.cs delete mode 100644 src/ColumnPadStudio/Services/WorkflowService.ReadableMarkdownExports.cs create mode 100644 src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.ConnectionDraft.cs create mode 100644 src/ColumnPadStudio/WorkspaceColumnEditorCache.cs create mode 100644 tests/ColumnPadStudio.SmokeTests/AutoRecoverySmokeTests.cs create mode 100644 tests/ColumnPadStudio.SmokeTests/EditorServiceSmokeTests.cs create mode 100644 tests/ColumnPadStudio.SmokeTests/ImageSafetySmokeTests.cs create mode 100644 tests/ColumnPadStudio.SmokeTests/InfrastructureSmokeTests.cs create mode 100644 tests/ColumnPadStudio.SmokeTests/SmokeTestContext.cs create mode 100644 tests/ColumnPadStudio.SmokeTests/ThemeAndControlSmokeTests.cs create mode 100644 tests/ColumnPadStudio.SmokeTests/WorkflowSmokeTests.cs diff --git a/.editorconfig b/.editorconfig index 2c8b872..ba59a21 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,6 +10,12 @@ trim_trailing_whitespace = true indent_style = space indent_size = 4 +[*.cs] +# Keep service APIs instance-based even when a current method has no instance state. +dotnet_diagnostic.CA1822.severity = none +# Prefer interface-shaped collection boundaries over analyzer-suggested concrete types. +dotnet_diagnostic.CA1859.severity = none + [*.md] end_of_line = lf trim_trailing_whitespace = false diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 0000000..ef687fe --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,48 @@ +name: Windows build + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore .\ColumnPadStudio.sln + + - name: Check formatting + run: dotnet format .\ColumnPadStudio.sln --no-restore --verify-no-changes + + - name: Build + run: dotnet build .\ColumnPadStudio.sln -c Release --no-restore + + - name: Run domain checks + run: dotnet run --project .\tests\ColumnPadStudio.Domain.Tests\ColumnPadStudio.Domain.Tests.csproj -c Release --no-build + + - name: Run app smoke checks + run: dotnet run --project .\tests\ColumnPadStudio.SmokeTests\ColumnPadStudio.SmokeTests.csproj -c Release --no-build + + - name: Verify single-file publish + shell: pwsh + run: | + dotnet publish .\src\ColumnPadStudio\ColumnPadStudio.csproj -p:PublishProfile=FolderProfile + $files = @(Get-ChildItem .\src\ColumnPadStudio\publish -File) + if ($files.Count -ne 1 -or $files[0].Name -ne 'ColumnPadStudio.exe') { + throw "Expected publish output to contain only ColumnPadStudio.exe." + } diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e4cf8..c453634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to this project are documented in this file. ## [Unreleased] +## [v2.5.0] - 2026-08-07 + +### Added +- Added saved Standard (320 px), Custom (220-5000 px), and Fit Columns to Window sizing, plus one global Snap control and adjustable spacing. +- Added a saved workspace-wide line-number gutter width from 32-160 px. +- Added theme-aware per-column text colours with presets, custom hexadecimal colours, and layout persistence. +- Added Ruled, Soft Ruled, and Strong Ruled paper styles aligned with the editor and gutter. +- Added an automated Windows build gate and explicit version signatures for saved and exported files. + +### Changed +- Replaced Markdown document/export support with concise, readable JSON text exports; native `.columnpad.json` layouts remain the full-fidelity format. +- Embedded picture data in native layouts so saved workspaces remain portable if an original picture is moved or deleted. +- Moved the app, tests, automated build, and one-file release profile to the supported .NET 10 LTS runtime. +- Reworked recovery into complete atomic generations with fallback, and expanded workspace dirty-state tracking to include tab and session changes. +- Simplified Workflow Builder creation, connection editing, save/import/export actions, and automatic node placement while keeping older workflows compatible. + +### Fixed +- Fixed column adding and resetting so pixel widths remain stable, horizontal workspace scrolling returns when needed, and long text scrolls inside its own column. +- Made Snap change only the shared gap and made Fit an explicit equal-width mode that preserves saved pixel widths. +- Fixed selection highlighting, keyboard-focus borders, paper alignment, gutter updates, and repeated blank lines during paste. +- Fixed save, exit, session-replacement, and crash-recovery paths so unsaved work and the previous healthy recovery generation are preserved. +- Prevented text and JSON exports from silently discarding pictures or rich column formatting by requiring a native layout save. +- Added stable workflow node IDs and migration for older step-list workflows. +- Quarantined malformed preferences, bounded crash-log growth, and strengthened layout, workflow, image, and recovery validation. + +### Tested +- Added coverage for column spacing preferences, text-colour persistence, paper alignment and compatibility, selection contrast, keyboard focus, preference quarantine, transactional recovery, portable pictures, schema validation, migration, and import limits. +- Completed the Release build with no warnings or errors, passed 51 domain checks and 558 app smoke checks, and launch-checked the single-file Windows executable. + ## [v2.4.1] - 2026-08-06 ### Windows Build diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..a63a7d2 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + latest-recommended + true + true + + diff --git a/README.md b/README.md index b1e9068..16d830d 100644 --- a/README.md +++ b/README.md @@ -3,72 +3,89 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) ![Platform: Windows](https://img.shields.io/badge/platform-Windows-0078D6) -ColumnPad is a Windows writing app for notes, plans, prompts, checklists, and structured text. It gives each idea a clean, side-by-side column while keeping workspaces local, recoverable, and easy to export. +ColumnPad is a Windows writing app for notes, plans, prompts, checklists, and structured text. It keeps ideas separated in clean side-by-side columns while workspaces remain local, recoverable, and easy to export. ## Project Status -Active development. Current release: **v2.4.1**. +Active development. Current release: **v2.5.0**. -Release notes: [v2.4.1](docs/releases/v2.4.1.md). +Release notes: [v2.5.0](docs/releases/v2.5.0.md). ColumnPad is a portable Windows desktop app. It does not require an account, cloud storage, or an always-on connection. ## Download and Install -Download the latest `ColumnPadStudio.exe` from the [GitHub Releases page](../../releases/latest), save it in a permanent folder, and run it like a normal Windows application. +Download `ColumnPadStudio.exe` from the [latest GitHub release](../../releases/latest), save it in a permanent folder, and run it like a normal Windows application. -The released executable is self-contained: Visual Studio, Git, and the .NET SDK are not required to use it. +The released executable is self-contained. Visual Studio, Git, and the .NET SDK are not required to use it. -ColumnPad is not code-signed yet. Windows SmartScreen may warn the first time it is opened; download releases only from this repository. +ColumnPad is not code-signed yet, so Windows SmartScreen may warn the first time it is opened. Download releases only from this repository. ## Screenshot ![ColumnPad desktop interface](docs/columnpad-screenshot.png) -The screenshot reflects the current three-column writing surface and contains no personal or sample document data. +The screenshot shows the current three-column writing surface with clean sample content and no personal data. ## Main Features -- Side-by-side writing columns with resize controls and workspace tabs. -- Single-text and column modes for different drafting styles. -- Plain-text, Markdown, native layout, and workspace-session open, save, and export flows. +- Standard 320 px, saved Custom 220-5000 px, and explicit Fit Columns to Window sizing. +- Stable fixed-width columns with automatic main-window left/right scrolling as more columns are added. +- One global Snap All Columns Together setting with an adjustable gap that does not resize columns. +- Per-column resizing, freeze/unfreeze, reset-to-default actions, and independent vertical scrolling for long text. +- Workspace-wide adjustable line-number gutter width from 32-160 px. +- Workspace tabs plus Single Text Mode and Column Mode. +- Plain `.txt`, concise readable `.json`, full-fidelity `.columnpad.json`, and multi-workspace session files. - Auto-recovery, crash logging, and save-before-exit safeguards. -- Line numbers, word wrap, spell checking, proofing-language selection, lined-paper mode, and paste helpers for bullets and checklists. +- Line numbers, word wrap, spell checking, proofing-language selection, and paste helpers for bullets and checklists. +- Ruled, Soft Ruled, and Strong Ruled paper styles aligned with the editor and gutter. - Light, dark, and default themes with saved preferences. -- In-column pictures with drag-and-drop placement, proportional resizing, and text layering. -- Workflow Builder templates, node colours, workflow JSON import/export, and readable text or Markdown workflow exports. -- A quiet, best-effort check for a newer stable GitHub release at startup. +- Theme-aware per-column text-colour presets and custom colours. +- In-column pictures with drag-and-drop placement, proportional resizing, text layering, and portable native-layout storage. +- Workflow Builder templates, node colours, connection editing, workflow JSON import/export, and readable text exports. +- A quiet, best-effort check for newer stable GitHub releases at startup. ## Supported Platforms and Technology - Windows 10 or Windows 11, x64. -- C#, .NET 8, and WPF. +- C#, .NET 10 LTS, and WPF. - Self-contained, single-file Windows publishing for releases. -## Privacy and Configuration +## Privacy and Local Storage No account, API key, server, or project-level configuration is required. -ColumnPad keeps preferences, recovery data, workflow-library data, imported image copies, and crash logs in app-managed local application storage. Native layouts retain references to imported images rather than embedding image data, so keep the image copies with a layout when moving it to another computer. +ColumnPad keeps preferences, recovery data, saved workflows, imported image copies, and crash logs in app-managed local application storage. Native layouts embed bounded image data so a saved workspace remains portable if the original imported file is moved or deleted. -At startup, ColumnPad may make a brief request to the public GitHub releases endpoint to check for a newer stable version. The check never blocks writing or startup and does not send document content. +At startup, ColumnPad may make a brief request to the public GitHub releases endpoint to check for a newer stable version. The check never sends document content and never blocks writing or startup. ## Using ColumnPad -1. Create columns or a workspace tab for each topic you want to keep separate. -2. Write directly, use the View and Columns menus to adjust the editing surface, and use the editor menus for search, paste, and checklist actions. -3. Use File commands to open, save, export, or restore text, Markdown, native layouts, and workspace sessions. -4. Open the Workflow Builder from the Workflows menu to start from a template or import a workflow. +1. Create columns or workspace tabs for the topics you want to keep separate. +2. Use **Columns > Column Width** to choose Standard, Custom, or Fit Columns to Window sizing. +3. Use each column's **Actions** menu for rename, move, resize, reset, formatting, pictures, and other column-specific actions. Right-clicking a column header is reserved for renaming. +4. Use the **View** menu to adjust gutters, paper style, themes, wrapping, proofing, and Single Text or Column Mode. +5. Use **File** commands to open, save, export, restore, or print text, JSON, native layouts, and workspace sessions. +6. Open **Workflows** to create, import, edit, and save reusable workflow diagrams. + +## File Formats + +- `.txt` stores normal text documents and readable multi-column text exports. +- `.json` stores concise readable column text exports. +- `.columnpad.json` stores full layouts, including columns, formatting, settings, and embedded pictures. +- `.workflow.json` stores editable Workflow Builder diagrams. + +Markdown document and export support has been retired. Existing Markdown files remain readable in ordinary text editors but are not presented as a ColumnPad file type. ## Building from Source Developer requirements: - Windows 10 or Windows 11. -- .NET 8 SDK or a newer SDK that can build `net8.0-windows`. +- .NET 10 SDK. - Optional: Visual Studio with the .NET Desktop Development workload. -Clone the repository and build: +Clone and build: ```powershell git clone @@ -77,7 +94,7 @@ dotnet restore dotnet build .\ColumnPadStudio.sln -c Release ``` -Run the app from source: +Run from source: ```powershell dotnet run --project .\src\ColumnPadStudio\ColumnPadStudio.csproj -c Release @@ -89,7 +106,7 @@ Publish the portable single-file executable: dotnet publish .\src\ColumnPadStudio\ColumnPadStudio.csproj -p:PublishProfile=FolderProfile ``` -The publish output is `src\ColumnPadStudio\publish\ColumnPadStudio.exe`. +The release output is `src\ColumnPadStudio\publish\ColumnPadStudio.exe` with no loose runtime files beside it. ## Testing @@ -100,29 +117,29 @@ dotnet run --project .\tests\ColumnPadStudio.Domain.Tests\ColumnPadStudio.Domain dotnet run --project .\tests\ColumnPadStudio.SmokeTests\ColumnPadStudio.SmokeTests.csproj -c Release --no-build ``` -Before publishing, also follow [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) and the visual checks in [docs/UI_QA_CHECKLIST.md](docs/UI_QA_CHECKLIST.md). +Before publishing, also follow [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) and [docs/UI_QA_CHECKLIST.md](docs/UI_QA_CHECKLIST.md). ## Project Structure ```text src/ColumnPadStudio/ WPF app shell, controls, services, resources, and workflows -src/ColumnPadStudio.Domain/ Pure text, list, and workspace rules +src/ColumnPadStudio.Domain/ Reusable text, list, and workspace rules tests/ Domain and app-level smoke checks docs/ Release notes, architecture notes, workflows, screenshots, and QA guidance -tools/ Maintenance and asset-generation helpers +tools/ Maintenance helpers ``` -For a detailed guide, see [docs/REPOSITORY_STRUCTURE.md](docs/REPOSITORY_STRUCTURE.md). Larger changes should follow [docs/APP_BUILDING_STANDARD.md](docs/APP_BUILDING_STANDARD.md). +See [docs/REPOSITORY_STRUCTURE.md](docs/REPOSITORY_STRUCTURE.md) for more detail. Larger changes should follow [docs/APP_BUILDING_STANDARD.md](docs/APP_BUILDING_STANDARD.md). ## Known Limitations -- The portable executable is currently unsigned and has no installer, automatic update, or uninstall flow. -- The app is Windows-only. -- Imported images remain local files referenced by native layouts; moving a layout alone does not package its images. +- The portable executable is unsigned and may trigger a SmartScreen warning. +- ColumnPad is Windows-only and currently has no installer or uninstall entry. +- Update checks notify the user but do not install updates automatically. ## Contributing -Keep changes focused, preserve local-data and secret exclusions, update relevant documentation, and run the Release build plus both test suites before opening a pull request. Add or refresh screenshots only when the visible interface has meaningfully changed. +Keep changes focused, preserve local-data and secret exclusions, update relevant documentation, and run the Release build plus both test suites before opening a pull request. Refresh screenshots only when the visible interface has meaningfully changed. ## License diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 930ca66..1f842d1 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -22,6 +22,7 @@ dotnet build .\ColumnPadStudio.sln -c Release ``` Expected result: +- `0 Warning(s)` - `0 Error(s)` ## 3. Run domain tests @@ -49,7 +50,7 @@ Expected output: - `src\ColumnPadStudio\publish\ColumnPadStudio.exe` - No `.pdb`, `.dll`, `.json`, or loose runtime files should remain beside the EXE for the public release asset. -Note: the publish profile pins the self-contained runtime pack to the cached .NET 8 patch version used for release builds. If this version is changed, restore the matching `win-x64` runtime packs before publishing. +The solution targets .NET 10 LTS. The self-contained publish resolves the latest available .NET 10 patch so the public EXE carries current runtime fixes. ## 6. Manual UI sanity checks Run the fuller UI checklist in `docs\UI_QA_CHECKLIST.md` and check the app-building standard in `docs\APP_BUILDING_STANDARD.md`, then at minimum confirm: @@ -57,12 +58,22 @@ Run the fuller UI checklist in `docs\UI_QA_CHECKLIST.md` and check the app-build 1. Launch `ColumnPadStudio.exe`. 2. Open a saved layout or text document. 3. Confirm the selected theme still persists after closing and reopening the app. -4. Add/remove columns and verify scroll behavior still works. -5. Switch between single text mode and column mode. -6. Open the Workflow Builder and confirm preview/editing still works. -7. Save and reopen a `.columnpad.json` layout. -8. Verify recovery prompt wording is sensible if recovery data exists. -9. Right-click a column header, editor, line gutter, workspace tab, and workflow node; hover nested menu items and confirm hover colour plus text contrast are readable in light, dark, and default themes. +4. Under Columns > Column Width, select Standard and confirm new columns open at 320 px without shrinking existing columns. +5. Select Custom, enter a value from 220-5000 px, add another column, and confirm the new column uses that default while individually resized columns keep their widths. +6. Add enough fixed-width columns to exceed the window and confirm the bottom scrollbar moves the main workspace left and right. +7. Select Fit Columns to Window and confirm columns share the available width equally; switch back to Standard or Custom and confirm saved pixel widths return. +8. Freeze a resized column, then use Reset Selected and Reset All; confirm the affected columns return to the current default width and become unlocked. +9. Turn Snap All Columns Together on and off; confirm only the global gap changes and no column width changes. +10. Change the column gap and confirm existing plus newly added snapped columns follow the setting without shrinking. +11. Paste enough text into one column to overflow it and confirm only that column receives its own vertical scrollbar. +12. Apply per-column text colours and confirm they survive theme changes and layout reload. +13. Change the font size and confirm Ruled, Soft Ruled, and Strong Ruled paper stay aligned with text rows in every theme. +14. Select text, move focus, and confirm active/inactive selection plus keyboard-focus borders remain readable in every theme. +15. Switch between single text mode and column mode. +16. Open the Workflow Builder and confirm preview/editing still works. +17. Save and reopen a `.columnpad.json` layout with pictures and column formatting after moving the original picture file. +18. Verify recovery prompt wording is sensible if recovery data exists. +19. Right-click a column header, editor, line gutter, workspace tab, and workflow node; hover nested menu items and confirm hover colour plus text contrast are readable in light, dark, and default themes. ## 7. Release metadata 1. Update `CHANGELOG.md`. diff --git a/docs/REPOSITORY_STRUCTURE.md b/docs/REPOSITORY_STRUCTURE.md index 41b41e6..69db6ca 100644 --- a/docs/REPOSITORY_STRUCTURE.md +++ b/docs/REPOSITORY_STRUCTURE.md @@ -6,6 +6,7 @@ This repository uses a clean `src / tests / docs / tools` layout so application ```text . +|-- .github/workflows/ |-- src/ | |-- ColumnPadStudio/ | `-- ColumnPadStudio.Domain/ @@ -18,6 +19,8 @@ This repository uses a clean `src / tests / docs / tools` layout so application | |-- releases/ | `-- workflows/ |-- tools/ +|-- Directory.Build.props +|-- global.json |-- ColumnPadStudio.sln |-- README.md |-- CHANGELOG.md @@ -38,26 +41,26 @@ Important areas: Main shell startup/state coordination and column-control construction, sizing, selection, and event wiring. - `MainWindow.FileSession.cs`, `MainWindow.Lifecycle.cs`, `MainWindow.WorkspaceSessions.cs`, `MainWindow.SaveBeforeExit.cs`, and `MainWindow.DestructiveActions.cs` Open/save/export/print commands, recovery/autosave lifecycle, workspace-session JSON handling, exit-save prompts, and destructive-action confirmation wiring. -- `MainWindow.EditorSurface.cs` - Column actions and selected-editor commands. +- `MainWindow.EditorSurface.cs` and `MainWindow.ColumnFormatting.cs` + Column actions, selected-editor commands, and per-column text-colour coordination. - `MainWindow.Search.cs`, `MainWindow.ViewModes.cs`, and `MainWindow.Shortcuts.cs` Search/replace, theme/view mode switching, workflow launch, and keyboard shortcut routing. - `MainWindow.Workspaces.cs` Workspace tab lifecycle and rename wiring. - `Controls/` - Reusable UI controls and dialogs. Large controls are split by responsibility: the column editor keeps image, spelling, paste, gutter, menu, and interaction behavior in named partial files; Workflow Builder keeps canvas interactions and file actions separate from window lifecycle. + Reusable UI controls and dialogs. Large controls are split by responsibility: the column editor keeps image, spelling, paste, gutter, menu, and interaction behavior in named partial files; `PaperBackground` draws paper patterns from the real editor line height; Workflow Builder keeps canvas interactions and file actions separate from window lifecycle. - `Resources/` - Shared WPF resource dictionaries loaded by `App.xaml`. `AppResources.xaml` is only an index. `ThemeBrushes.xaml` contains app brushes, system colour overrides, and shared geometry. `ControlStyles.xaml` contains reusable WPF control templates. `MenuStyles.xaml` contains shared menu and context-menu styling. + Shared WPF resource dictionaries loaded by `App.xaml`. `AppResources.xaml` is the index and owns theme-neutral values. `Themes/` contains one complete palette each for Light, Default, and Dark modes. `ControlStyles.xaml` contains reusable WPF control templates. `MenuStyles.xaml` contains shared menu and context-menu styling. - `ViewModels/` Writable app state for the shell, columns, workflows, and workspace tabs. Larger view models are split into named partial files. `ColumnViewModel.Checklists.cs` and `ColumnViewModel.Images.cs` own rich column behavior. `MainViewModel.TextDocuments.cs`, `MainViewModel.LayoutPersistence.cs`, and `MainViewModel.Persistence.cs` keep text documents, native layouts, and save coordination distinct. - `Services/` - Focused single-job helpers, including `AppStoragePaths` as the single place for app storage folders and `GitHubReleaseUpdateService` for the optional, non-blocking stable-release check. Workflow storage, serialization, and readable text/Markdown exports remain one service split into clearly named partial files so the format has one source of truth. + Focused single-job helpers, including `AppStoragePaths` for app storage folders, `ColumnTextColorService` for validated text-colour values, and `GitHubReleaseUpdateService` for the optional stable-release check. Workflow storage, serialization, and readable text exports remain one service split into clearly named partial files so the format has one source of truth. - `Models/` Small shared file and storage contracts that are used by both view models and services. - `Workflows/` Workflow models and built-in template catalog. - `Assets/` - Icons, splash, wordmark, and app branding. + The compiled Windows icon plus reproducible icon, splash, and wordmark branding sources generated by `tools/Generate-BrandAssets.ps1`. ### `src/ColumnPadStudio.Domain/` Pure rules and parsing helpers with no WPF UI code. @@ -71,7 +74,7 @@ Current sub-areas: Workspace import detection and workspace constraints. ### `tests/ColumnPadStudio.SmokeTests/` -Broad tests for shell-facing behavior like layout save/load, recovery, export/import, and view-model state. +Broad tests for shell-facing behavior like layout save/load, recovery, export/import, view-model state, and live WPF resources. The entry point coordinates focused visual/theme, infrastructure, workflow, and editor-service suites through one shared result context. ### `tests/ColumnPadStudio.Domain.Tests/` Smaller focused tests for domain rules. @@ -92,6 +95,12 @@ Important docs: ### `tools/` Helper scripts that support maintenance or asset generation. +### `.github/workflows/` +Windows CI that checks formatting, builds the Release solution, runs both executable test suites, and verifies the portable publish contains one `ColumnPadStudio.exe`. + +### Root build settings +`global.json` keeps local and automated builds on the .NET 10 LTS SDK line. `Directory.Build.props` applies the recommended analyzers, code-style checks, and warning-as-error policy to every project. + ## Architecture Notes The app is intentionally lightweight: @@ -116,7 +125,7 @@ The app is intentionally lightweight: ## Maintenance Guidance - Keep `bin/`, `obj/`, and `publish/` as generated output only. - Keep app-wide WPF resources in `src/ColumnPadStudio/Resources/`, not directly in `App.xaml`. -- Put new shared colours and theme brushes in `ThemeBrushes.xaml`. +- Put theme-specific colours in the matching file under `Resources/Themes/`, and keep the same resource keys in all three palettes. - Put reusable control templates in `ControlStyles.xaml`. - Put shared menu and right-click dropdown styling in `MenuStyles.xaml`. - Put new app-facing helpers under `src/ColumnPadStudio/Services/` only if they have one clear job. diff --git a/docs/UI_QA_CHECKLIST.md b/docs/UI_QA_CHECKLIST.md index 984ebd2..5ad4803 100644 --- a/docs/UI_QA_CHECKLIST.md +++ b/docs/UI_QA_CHECKLIST.md @@ -14,6 +14,8 @@ Use this before calling a build visually ready. The goal is to catch the rough e - Open right-click menus on column headers, editor text, line gutters, workspace tabs, and workflow nodes. - Hover every nested menu item and confirm the text remains readable, including submenu headers while the submenu is open. - Check that menu hover states use the app theme colour rather than a mismatched Windows-default highlight. +- Select text, move focus to a menu, and confirm both active and inactive selections remain readable. +- Use `Tab` to move through buttons, drop-downs, tabs, checkboxes, lists, and text fields; confirm each focused control uses the same neutral theme border. - Close and reopen the app and confirm the last selected theme is restored. ## Editing @@ -22,18 +24,34 @@ Use this before calling a build visually ready. The goal is to catch the rough e - Check that line numbers stay aligned after paste, delete, undo, and resize. - Switch gutter modes between numbers, bullets, and checklist. - Toggle checklist rows from the gutter and from the context menu. +- Apply preset and custom text colours to separate columns, then switch themes and reopen the saved layout. +- Try Ruled, Soft Ruled, and Strong Ruled paper at several font sizes; confirm the editor and number gutter stay on the same row spacing. - Use `Esc` to clear selected text without disturbing other columns. ## Columns - Add, remove, duplicate, and swap columns. - Try deleting a column with text and confirm the warning is clear. - Try clearing all columns and confirm the destructive warning appears. +- Confirm Standard opens new columns at 320 px without shrinking existing columns. +- Set a Custom default between 220 and 5000 px, add columns, and confirm new columns use it while individually resized columns keep their widths. +- Add enough Standard or Custom columns to exceed the window and confirm the bottom scrollbar moves the main workspace left and right. +- Select Fit Columns to Window and confirm columns share the available width equally; restore Standard or Custom and confirm saved pixel widths return. - Drag column right edges and confirm locked columns cannot be resized. +- Reset a locked selected column and then reset all columns; confirm they return to the current default width and unlock. +- Turn Snap All Columns Together on and off; confirm only the global gap changes, widths stay unchanged, and no individual snap setting exists. +- Change the column gap and confirm existing, newly added, and loaded snapped columns follow it without shrinking. +- Paste enough text to overflow one column and confirm only that column gets its own vertical scrollbar. - Switch between Single Text Mode and Column Mode. +## Pictures +- Drop the same picture into more than one column and confirm every copy renders inside its own column. +- Move and resize pictures at different column widths; confirm resizing stays proportional and does not jitter. +- Switch a picture between in-front-of-text and behind-text placement. +- Save, close, and reopen the layout; confirm picture source, size, position, and layer are preserved. + ## Files - Open a `.txt` file and confirm it opens as a single text document. -- Confirm first save of an opened `.txt` or `.md` asks for Save As. +- Confirm first save of an opened `.txt` document or JSON text export asks for Save As. - Open a native `.columnpad.json` layout and confirm direct Save is available. - Save, close, and reopen a layout. - Open multiple workspace tabs, save a session, close, and reopen it. diff --git a/docs/columnpad-screenshot.png b/docs/columnpad-screenshot.png index 94cc9e1458b6a54f4ad7ee1a0646e6490cdc3329..77c30130e51a16a28a9d63b6a9e5aca3db104b2c 100644 GIT binary patch literal 30009 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 literal 33704 zcmagGd03Kp8$a4i@6^nc`t~+ui_6<&TA5m!nk&;ZWoGKAskxH5u9S)!B9@81)sG) zyJbst`i@P%@4)LHZeH|2ZrP&T2K{`~iFg^XWy>8a+uzP!iT7P#p&NY$W!tCdX}v$c zQ~P<>_4|2$=l=WbOOKCvc40T9gSM`A$1b_rc1p-LF0!KHk7yW_MS2;wa9t#v^mporFrH5|jTSipWiraD1~I z@G390y3!O)#hX~$14cpNfstI?!m^I;cJ&p}rHt@FzlQGJj+R!PPaFq}b7a*kLZks0 zp|n6QGmj9oim`#ocyYNUd%FEQ>acC5w!3+(_0s{8;+C0)QQX2PG^>D=DiqOD>Yd(Q z&lFg9KB4E|QI;e~P`tm29uNegwRuKqjhMQfj#7ixgZX&k_iF`OJ|s%>K!aoYDu3{B zr$$mh--Uw_zYE_Y(?@E3OI__nqs4)D;&?eDOW+n=++u^1{gr3a%yxMYM)OF%RP5}r z@D#zx@*1l3X|P|UD4v-kV=00+F%wBST3vQlg_UDTVxQT>G`qoP+ch7SD#YRF&B;W8 zdx9vz_#7KU4nJrS=Y7C9^ac&wdWvHJncJl#q@b6ueh9n~#^=ie!(mR0z7`XCisSv}Lx_V{9(P|Y!O-oX>W zW#?0}oN-N@mDyLHvb50ibHOzm^A)rFh6_0nsEJs|xG{9+e6s#-c{GQO>~3)EohvQ$ zzQ>Wqj!}ZrA5iojEq&cE>W#f-CpAu9yH@O9D&sc)N0?!UgJa*}LUPJ4X6&Y-J<(Lk z;-XtUd!68Qyps?gdW7t*s#7)=BjktmOF8W+N%l7CQCABS<#$)sB^m}VJjg*~@kL5u zh7_&##^q^U+h}pCIA^3mS}1kZb}aOk@C62>0`zbbCw55RFABUlGUs)rg4le?o}x5N zFG{z5q=inJI%qcYlvEPOoH}%FFj7`;o1s$}-|R4!o=CB}+O=S~n;A(<T)u9h zOsaB}ekx>Orh74Q(cNt6VbSqm`Aos32B~Iw(=5SsNG<-o6VhA3Cdz}&6nnh{-g6_k zUy7Ss+MzqeYyPyTg{~~DZ2Of^SX?C1;6AqycL-WnEOL%*%JSr5R~LojYFwGvzQK)( zUO{!Qjd!Yg$A@sTIgr9*79TD-&K{BI4AZiCStsf)LruSzWJ#*5FSPJZQXbr zVX-h&wleI6phqb|g8J&kiJR9jy~}Lku|Fybt>C%AH)6YrX(Y`0r(}-JX8!4r0v=Hz zw(K-Wy?tl$DCybDJsA0SIaltfXgU=N`b?AEVJB=M5zo`g8cBe`B^)%&Yhlj|M&Pl~ zoT8Wf#Y6!qrgeFCWkY#=o+@dVs0OzwnZD0&3-aNy#FKE3koG-DL6VBLtp;Z4Q*Bs- zFXM7bP-{JJ_LGgKV}|AO%wPx`J6$;v$J&|Te-TKxto!M0L;&3iMF$$~BcW*)WHAw~ z&!9PM3c9xI!eHV*emcAO!ZtH(IK1gXf@l-o*KrVWr62d!)UogO_LwdfYVrzl4542> zM(df(f)s+YXn`n4rYqt^FkMCCZ1ak>w>;SnxvlrYm2tWg#+7Twp5U4%~bq`?lgeIrD_ z7SXU*zftW0!+;x$jlx31(WDI=ov~AG^@8Ztd1uPKWt?Es(LQ6Ry7`G5p9DX%*;E1E zQ+mkG)hYXPX17y|eB*V?Mksp)yD5bGgiWfMSZ>GG+4UVn?Cgj1NyU2o<^y@huKnOK zwi$kHHZ$)JD_0eUK2l3q`ey&B>sO$ox+L2Wk&}`m!tRfE_;)_&S>10mP$DunmThY( zoPJ<+JU_g}$v&g@#>eMDg4VV7Nn+Szt&s6rTaFEE-4_oNyErP&*0Zt9D-HC%gLk%Z zH{qL=mXpBfg${>NA{Y5<*8(9y*s2moAFcM+)x<2Ft)C1{fzE1mQRW#pilrD{v0ht8 zjN~GtfWf19{U#Yl`Iok|-)3L_>Mz=7MtC_ffFr?LWcKc$^uEQ6!ZO4gBZB_Za zsWH-vwG%o9yYX+gckGz1G@`+`QIe#q@6W3iSg?PIa@zA|JowpByJbwe>Ht;N5omTl zR4Qn2#7m=kaKtLx4E1`pz9~D}ro=u!o1_^VuYLK+6JQ3O4YWvpgtUcgKJs?_+8t3? zHk=wlT<>x9@VgsiTQ%N+qihgTylL?psr$=l6Vdw(XQq`__c9*AEwt!XieD4NSUrlM z9!YIhP|;Iw79oKfLlHaBN5(E%jk*yc$$JGctdbXZqhH zUtSJrk>Y&)0m@~0!Z-CcV>9%Ead!pVCW#=sQ@n2l)4*K@l4(_tPV4Q~=8 zgB+Zm-S4Zw8zrR2DvN&f8b&ic`23f8nteqJ=>;U8@W1Pd`P_ zN_*Gs^wS!P;-Y=I;E9!ZqBY!$#BYurk7Fn0a~Dku4fa`3+(WDeNpX={$=_o~J~vLf0pS&AfCPE1HfpW}lf4GX^NY0>5Vc zszeq9fSSKUdDBLDPtSk$+1#FFa)GtPjd2p z@V802{$V@x>b0t!aBrC2z}vPX)VKSfJGTt9Z=ZsG+4A@6e{THozw2+;zUt>MXa9Ho z`5&bE^uPZfh*hgB_hj6a;^tlJ*YmAqMJ>kR7lV4A__Dq`aReCVva+(S169NJMHlSL zDL_T0To^6wp2m!b9oKJfo!)^JNozyv;$nrCY1qV=erukhJU=)2ZWC=$PiTZ$x>ZSD z+z1(c;)y548v7K&=6qzwel{tx zunn%*7&(oYw6s59dYS7$xq>oM*^z+DoO!@R#3EFGWxE&JN%c@KBjwHq&6n!J8;5>9 zwf@<&`S$V&gfjZD-blzlc8XAbUMV%_hSGEn9BW2X$OOp6CT`HXzjiTL#Rnr>ng)Qd z9;5E*q?{YOrA%h9{^BYxf-xqb?MGwLll1I)bWSXUA^Y}vVU8q8*GP+B2e(e%YkrcdkV}yt z&Ti+(r2+#c*yh5C*byI($RA2q?*u^M59-8xbUDBkQ&Cl^&>#ULGsLGpf_+CRz#sXJ#wbGorS_5;# zVi)gb$&}Avh$Al278^}e^pUzk`ms_R?(ghzXxDB9vVPs((ZI$g0-boIkkS-AeWvUJ z;1XvhkaYWuf~b!DW8q{8LVF0{8cW$K8L8wMmQ!(q5z05b`E_lH{5l&ChtG4R1ii3- z)9D|cwG9wi8;ri#pE$>xeNnNTVdi3a*TM&JFS%cjj$U)bT^YV;4ebhLp~ipAz4g+@ zlWCgIps2Y*m(c0jYe1Wz1SKmoOArK&u>_tTQp6_N2PL0WgZ^>hB#QRG07_&&_X4cTKq!q}XWvOS*DKV_;R-dJUXCbSEr>J?dyvvgeNE@B843=HrRu!w7U${Ildak*sXnywm%BL5$bJ4GN zG5VnYB{v6NXmt&hX)>C0fW*oIm+ilal?<2VAe$3EYr4=!KHQA15ceLcJvz!XSi z0vzq4MH@jjsOhSKU9X;>ZJJMDF#?~OHn_G5Ar%Q+YI#-9jN_SeF)yT6#*h&XX_`HN zRNBo3r*7Y4Jo56yjVCg+kQ7F&6k;4LKRw94pT@S{Zxa4ntWb5FiJ)al1x-iNVHlJKZhdi6M5k40 z)ck(oL4s~pcO|S_m(+M77>~XHcG3$$^I@HK*Tk&*ftb9#AbMq4@}(#Q4U#Pt$tON| z*O5w51ge?jF&fTnxpF)!RZZj;iwx>7$i>GAGVBbjNi8{P2TV>#QH!I+{p)DSa2eZC zV2vEULm(uH7as#(0!#AcyR%$31_`9@VpT%Ug*0m_vHg^PM{25?8Mk$x*-903Tml+Q z+Y7-yr4mP}YSB5zR?Ds%iOjvDypX|*Y%JS&M)0C-hy;U?nGsw`9bKMG7YUAlv%z?r zhyfX#9=!(dewbo28c9jfZ$4V(Sy!%M;%+(-k1Srh|9L)(R@XKS^I2}FS-V-`7RGgS z$9Q*K^ZV&dw3Ea0-;=x}2JKC{cNnZymgy%sRjL(wC4J5gBRZj>17qlx(!L+|D0L0Z zxqozlf-3b+l+k*o!{o7T-)6ly+9IBmW`WfdCrQ&XwV+fAUJg<`QBQDvw8-~)dLtL} zhj~L#U~JBnRjNe|EoTX_=%``JoEYGbkBeGGB~>TYj*R4M*{j6810k=s$*ZW zrbBrMKxq>w#ks7*56r32Y?(@ykU?boB*qG}a8jEwIp0M=f;@GWe5)@hb-z))b1&^o zVbbE=7U^PQlaQE*1g0=(^$V(-`mu<8jJdtV`lzB+N;OZ{1MXPSomJxBnP#9VoDb7P z#9yT(bw4hv(~woeH&;f?c%2G&mZdxR13H=B`Y@P~cnD}YLId|12a*(4MK-j|&cgWP zMQpSwbRQB4tvN|FNn*{ueP>$^zb|ILb^lgpyxHrfi}H%-g)Gqv)Q z0y8J#S*{+j4A*iBkeMkve*hxhHc&-X6Tpa@txw&lpv=yV7Nu7Mf9DEF7?iELn3wyANe>Op8dTmTt>2WE0+pvRv- zYhfY5X_$<2AM;gL7UO*){jtwh2Y#ni{#NZY@kir)H*6TBC5SDs_IuIEYM5W3%*WOG z^nIsRcH22$@d6VKRW@6%Fk5~x7zR0_Bq>(yXYH=$2+tfO8wfak?b!(9y z4#7#4Ezv1?rG)rbHJdB;x={QJ!ji&i!6e6h(-?X==xBKry;2qoY%DLUQv+8M&0r|H z)f-}~AdKe~*@T`<#9bDhQ|&QU%;yR98&BH~C`4x^3=)o@m1`2=iCrPXgH|3Nv(3Qy zhy|N7!Q^>6cgm)LJC}uG+hr8s-jG1hwB0r%Srgr&=JKxs)}o*g*T+%)28c_qok43x zn(6q{<}e~iW#tk!7dBUUiiC`rUNXJsuSeud_#tXxs1wYzM1W_12 zpF5Et#bs*IT9yXP7K2l=+ciUn`+gdKZ|%^#oUXSM?DI_x1o!riI6LytZ~u3QK%9mW>KJY! zOrP{@u#n7FxN0JG;VS9h=G;( zhDQ9CN1K9Nji#vQ`t$oMaGD!`Yi3xb)Bz**yolD3%IdB9mN7F89qT zP(gCEh|{&;{2%vC&itkffvYr%+y?D@sCksL=am;)#row1b=pp@D5OzrOz) zkZ#~6B#7todlVQ~c@(2EuVcGLLX5stWI+CSkDoDCho_rKZT4@>zH-D+>(bOst#Rvv zaN&##{Y8&8e*W?FQKA9a&J1jbRoX$*?%I&Sn%HEkl?jDL_lkY~tXC-(#+LRskR?HU zql8rpGZ6*Wt*4L&A2@yEVFOzOe@7W+LWjvdcngAqavaVqJls0_{iq^d!ma@sm%s`= zQ^yRon9+}tz9X72w!fpL9wb!4k2Zs|XhpgGlQXA)mw+dTG35@@SuX+5;YI@>?awgE zAc04B#6@3#atdaPpKJ*4-#ZQYC0<%5<=)#}C4tX7f)F zftQCY-~C$^4*S^ln1NU<*73WPi8XLOGc{ z(uYciv`;BG8FS&4EY3Eg&Xf3vj7{)0laKQdm&rkW)=|bwI%7Cm(uyEIQZJpeKL*$MBj_qXD`s$FVYs(iRb*n(! z5uAfOnyMjl$TJCXE{*Ur4j+B$a8thCh6)B|;pz;R$)}le`YFw>raHDDsyx5{%#9^) z)6R-2v~W+2(1AJ9R02Uu+vu+I+_h=<#h4hD?ZhH#$b8U+V+^zM)SbwO+FMq8YONx*$ozA+Q9Q7zC$K{f<339+gW zunS2deml*RiyHC*tJT_T7N8U@`E-xd38fLa5HZK%*3}SKUf;gcv!{zD>m!AN0@~Fz z8E>2@5#SXbGu#H7PF83fJAxZcb@!daySjTcC5$>(i+$CR!YWku!66@1n|-czQiAA} znWbdN2r5IZum)*alS#f6i-n~Z(hkg~y*bx2Js^oOu*{0-BG*%Il8Swef^mAND z^GWI^yaYk3;2GwM?oIm))MS#WZH<&@!MU5`>p;&*JVNe4f`Uq={g=Y{6&$jaAlsl7 z)_G!EG8$h=X7fwL6-hnQXe;*Jk1uSe7n+62O^f&PyXzqb7&n2XoDLZ{Bip{Sa`&Pb zKqlxQRvLkuApgT#|8)y<&Gd9UU9th3vyblAFj%{s z5njic-LZ2isFL(=a-Ia-*7kytg&7Piqql1t9YwwbLyB)OrI%As@wjvbQ- z;_oc;{Hdhuy)lE7cE3Rj@N{dH?2QuC>rzDTlj^MJYUg7mDa8>WcMHV{J){%^SyI1b zEWJEgFRBwW{TJlB8GXIOpt!!!-19)VGbM8d^7p{7lsWW$qQ3=oGo#4Ob-Mjmd)7z(fKkwhS{=6E>`Q8e=yQkxI=iWK~Q(}T> zp7Zby%@g}Wir%%t~CiXe-XUzwp6}zT*X8ih3{&?vK z`Q`2EjGw^mEnBp{_5XVP@qgD0En|`xeL?B#-CG8aluVf+k3#p2v?%)X|M?Se)71?@ zzWDzhBGC5!{=rfX-U$)Zu{u}wTh{&Jk`mu}#!yeF-iU46g|v!9s3fyxFY|f!i=$ho z8}2D}U#h6gPB2b6t+Jz7ThL-<$*UZpHh)bnU8~j$z2=?$;>oF`=2z=3F1B~Jxv74A zhhPwRt#W63X@##9rwAJH7G1Rrih%$BQ|C2jZbyl23VVNGn-@7Sv)p|wY?!7GV0t6o zQh|AdP?$;ZY5b5bsg1?Wbj6Go+X0{iXF;qLu;I1uZ%2W=HK)(-@ch3@9AQGtB%0fL zIoGf_JdwvoMUTFb=5)fhB`zSZMz+kYouFX`=k=kF-ZH2>ubi6sf|FNKN)MA|YHi)F z)U{pH;S#VjD{t*uGsY@zGHWBVvfMnY=O4kt)2-qYi83jnJpb!Kn4XgkLO&u{1Gh|J1@AYW5}87Gkpn-lk1~lZ^W!bT#l*SgRT)~`dTLIw@zheX zgoj=?ec2>;M}b_{y$Q4s28|-jvN!EN4{ZL1c-6(>C>`~-+Ge%^x5-o!?nNr8oz{YF z%t_`Z`LnC>8}iNhoM5grSpMAdpAt(B>-u*ccnf|uh)e=NK)?S~^c7``c!hag8aqLX z53ir=0HAhhfy#3ji{n;tcTJ{d?GKl_fKKs6=qK+NV7R`uCFBQ;}6s@Pgr->S0SxZaBwWGI}n!muJ7u&GAWD_1csq6?Lq`@tj-0VoRB@z=|07bAam3>+QSiKhsk}Gwf){- z7uJ)}uAB-!Zv_B%#cp{dkeC+PnnfUU8-ILO6()7U#tch1_o6#7vRCFuj?d15vwZI^ z<`uBk%(YB6rS;M0nu{FISOv?cRt=v<0wRK$z(PRHN$H2_9;^+^S8rgOB;rIbA&mqF z@w7l}s!GUDx88W!UxGqXI==I0|5_ni3Um_6Ef8)Q0qav(kEFi33MotsqV63aj^WXr zGS`KXo1>>ZlK23c|IZQ~uV^SMi}d<;O6&ir!fpRw6|O7inO3`M)<1Ug^}utjuV!FN zO0nCumW#KIQeI|Uiroz@|8;Qr&eX7ZhTZ%RX|O%UBE#73nRz$pom=!P9&PJIWiBgg zf4u=kv#5E7OWF01vKJX;F51C$*;RutLdf%9323+4>t7nDhAlQuKe!~^lTC{JD#BAc zUV4yA#{4>cRP|0`8>M%$s_I7@o7KM#ZtxT}c`(y{98m{s@%Y!NY|*j})rg;>cF$MT zz5G8zgz_TdykG3;;FO@C&uzh&omeSBAylvq*LI%Bv@UQq7%a6fkKA@udkFDiDDl6q zdOgXmlzHtf>&|6YGP2wvpz#m~^*+2p`4hKO=m*(BRm)Kpr826}a-lD58Gp0RMI;&)Wj7mX&K{G}or$LY{~&>oQz7 zC)oyFCKPJh=La=$vMUDbdfE9B$)AbtFWFD$Fzl_Rm+^Vi4ss`xv6ntrbiX)LYr501 zHv+{XWIljBPlylc9x9W}mIZU3!#7Xz>LPqttwWb{%>i>@*}Hk6H4)7(#qplROhhEl zKf`=;LP!!i#;dy@&$>)lb2@G;%5JwX!WkRqn)xRjI34~Z!59fYTo+Uk$FSeBRP0=) zrCT)~`UrdF>eJWp;M_^O_ZqyUA3v$TIzI^GdN1kRURE6iDc$b-x>b4G(hdN8X9QN0 zEuK`z&+4A88kF~2_zyJm?}bd%df?-%P54E}(EhJ0Jdk~^6_kwccBaDTSO&Pw9@^ex zH)WOIPLJ>9Zb%lA&O}`wucSF;BZga=!*Wl>cE8v;uL#XECMBv&TbOc2pf)Hf!L4Ok-G|nwS;#@Gz64=@3o(zDs&;c5NN@^R>e2kU{Hi zsepy#9 z_tFL%=T;bfJ2;;@3;O|~!h_}AxvMjYw7D*%_bVb|Q5RZ`&Dm#`rGX)Tjd!+-6^(1N z=0~*dn>n80=x{893($U%>vX)?)KVrFO$^=2-Uo%zzN}u=-}*q|dho1m#_7sBzoUq!fL3m}3+FbuTwp-+lWa%-^t%RrNAXiZRg}kWcB&U_nGlBx2;Qr?? zeslW#d6r2@`rH(nZx|a2>TpZDNd&qnNr;stN)lH-XEtzpji|YqRHVb&M1=I~ z`(SH%LuaYei;GQPRD&Pu7OK9;HiTaj`Y5R3FLRkTbB~{0{jX@gPgXR#BXs?*hQT;i zY7+R^pIB(QkI6AVd`kOvf5SO8A^67 zG`)0p&Cprw`!=81>h-%zi9=sw)b4=<4cPVvny^#J*y-AW#9@^1Vgqq5uG1L3^hrm! zo=JBFWUX`el<9e4mDx7UvG#5El%jjPV7-S~&9e`13S%qZd7t64g&-JjhHb<^(^AEY zizZ?b;`y?Su|qMXvh=P)Hu$w%(#@ddcs$*(VWirEd@XddW#^WZfPR8?c_ljz-(rTj z5{nEOdWni=5U?H^iiOV7nc^IHg;CI1@6{Be46TERMqf{2wdVuNaGF-`;Y#b(*ZpJH z$)Ip!5lIW2+-KP00DGNL>ri2A6!2w&9Slib1##l%(!$NO8vZ+f6*|(O#W{Flv+~ui zl@Uf^#6&eLEpxz;pm4qR4JN#vmnTvj61Gi+K6lD%50- zU~_5HTanJrLVj~1wIl67U~yJn@TetLdBNh^?{Q;M%o^*P(-+dBr?82f>bpBrnM8_% zhv9X8cG%1mDb4{B;*)xA8}YFrfU+kfYtlDj#@xJW=|A>m6L=*F7ICA;(_?sf7Kn{7 z>_!T^7j?uCu)0SuTc^`Zy#aON+AV`SCRHQ8U$(51<9K*6hvS=svXoxE z9*%74_;vehf@<19<8Boy%DIrg91SI8mVTH$xDQc1)&c20_ob@$WBk>yDhBWIQ^>XT z)y*z@hE;)Vn)RdJ$<)QFj{-N9Z`LOygvIDLuNrt%a1Njmy`$zw zj)D?e1aipI7u?CU_P>zOGHPMFLgV^&&m42%rFvv&EXV_zuJ6AVg%LlVURfm@40{wy zAr00f4-O2lS>vM|r|x zBPFq(#a}8wz99@mr!mTtU;m6=F3v%A72}T5tnJ0HrJIYmsZaB>jxiiKsq09N{Ez{) zcGcYyAoVkRld<~E1Zt-p<6L&0#bQfCe-mo5)ecV`?-WLN;>EKsiyVoO@*!5VY>#BA zt&4NPjxG5lk@O$y)UuVw&za4wy)Fu5uk@IfxjcWqRClg<#uV3h9KQzBhWu9E=|brP zR!SJqRHF^NIC}jfyNtYxp~EnrP2rbvy?>|nuT2W3)2_pmY}AvY0l{=~einTI6pHId z;~Hw20nD9hryH9S|E5rReX*0t*7i_-3y%bwcPw@zRFmFg#`OU_8%krd#HH@@eci`( ze}^GVPy@?00quL--=F8oj;i`@a3KJ*fcs)Q7EEGw|7TAfcQmjwVx>wTKLO3uzvDs0 zAWf^zi)4>3bdYLhGTc0xPN3f83_i0_gH!t*6qC77fgU6{Y7$GgLgoB&Jw4R-4Id^1J< zUAQ*I4cG194!={;4i*`Y5F_=|00jsXgzW&kYddF|S;X$DXJX7N{=WaJv4@)Iit1ye z7_=6ALsIGf2v>M!%ycLw^3qJgP1!tkshkor@p5rCqKK`YlQur$)8)xb1f?V@T;?fX zDP;#pm6r#c;BRRKWqzN@jOaANM66x|`#^)*apB`@aJ z?OK4UZGe&qF)T<4U!0?cKI(A)YE$WdmW-(b#WHL{|92kMXkmdYQ=nrT2Skq8I%^BmvLQS^EAu^(4(Bmpo$AN{(TC#a-zOMsaOfFY6qNaYb@1`M zzv5{L55|dt1SHVM8<8!G(`mQ4L6KtO@3gZng4-BkpCDF#M^j-5l39--Zb7arFPF&+ z40&RSzO*KrABkpU(dZqZrGjPUo~#jtaWG$gvcN7zcBHE3TZygDkXlYEreY!i2pKF1 z6d7|{ZQg*K-DQ$5X_m=0e`e`R<2QT6*11+B>+dm*X*KhTT+RxwA1m1p+9u@ah!Hhh zuhWiL`8e_lFHUg|oRpI}U()qS`b2zzjwF0B_NuFh{PvbX)}SJ+A8z(Z3dG>`BgY z>7Ko~T`gkylS&$vxe^Z~Mj!mKR^5$iXE%66U5jY#3rL(yAunC)RUNNqhG+S;UFdb% z`@H$eJIJIQzL0u3h_s@&5sJG)Ou+D+RT)A$9Wa2nM>Cf{+ZmKf+tPzaU+rp|8GwBi zUGVW2;2yV|=IdhBex3Q0mEeJuaJWb3e)pO66Udb5kO=kq)_JY+TiKsDHkK8gBl0IG z(WWq(eTOxOCOzn~BtCm0C=luw`u79*E!zB^`^xXI;mr?IF=kVLrDC}($bzhF_<11T z-0jmi1}qaq9!5^Tn(k>2J4JK4#6)< z_NsCWneHRZB-2SQUw4_H*hpW%eQ*j?Kw2T+x^0$@li5g`nK3qb4IbQ%H~jUUnXERmywu}ojb=gpX)iy? z*JZC3?8wl{j~KRY3x?$YB1rK3P}!V(Y1%e}x!7pItB>%pj{BXaYV?wwvfn7q8^6)% zE|ZO$CkcNHhc8GjNmNQe-a}spf2Lv)5urm!xZoC9?Q8~5S4|k6!&P&20et2gf)9g< z=^;4`MK37O^+45KV>h@SC@!BFLHl{U0=*0?U+$ndIkXM?aYp%$mPqC>Kw-aU%=A+z z#UX>*=#?&ygK1WxAu^ynu0h();VpLXh~4Z^MuVDVzzw(X$(98QaJDD5awSc(a8Pj+ z8azfKT1B%>;BGI;VB@?pY~gi5%;!_+FI46VO=ppQpn{YQmB$q8Oains+eC?{AbA|` zQ$}Y7w1Ix5htCk&T7SzsrVa)(i~&Zf8n>GvJX^OJA+>H?$j(_I50b~? zSNS>jlnw+eEhHKTEPa4@jmd#)d(JS7Ul(COB5uL z|KI>h*s)Gc)Rk;FR$?xLT2`u1o2GCULQs%2tWqPG#2a3pd1>zNi*{9c@dW z4httKf;Dq}5T zMw@p&>`c%9J-ez=aXXn;`7KfXN7dMSnE^d|Cb~dA(21)S`(b{+njyr=n)LOk-Q~$i zq4MPhSRfGfL2|HQ6R1UZRwrZ^8(2{&Gk|_)nmsK z7f2&{?K;TmSoh+dZ)^glp1uLY%7mlSjykqbN6GdDn&Y;eYL9_e2oN#cb$i^@VpqFD zOcT%51CN*WY$+jPM31)6qq)j#lPFcc03|-JkScgmGQA|FH3reBTstV(03U|(n zuj>Ol8aV2?`R&4+#q`-1yVH+todJZ_tU%rVvbqqTppjqnhlmF;L%Wi*ZzJn|{(cgu zm{83qddMr*`T`Ez9-f2E|8?%4`41K*F603m(%Z$htlya?p70-up$ML(?zb?3WEFHR z2tjH2hA*1KSg1Xh9I$q21|P>Xg?s?6WfOr$jxI&_w*nf06g5|%Wj6PxfA?$jSwLt1 zO`ag4b&JzMN!LyxCxKY_N-c1>*ws6N*)K)PbhZM0ZZc8a8V;;9=tim(&olrM!dia# zV^Ebf0CHsq%J1DZd)iB4O($c#wCxL>@4c<4oOoV&aH6@-9Q2#*4V?0>`u-1|fjffe zukG;U$(}r8-LRh;fEa@kg&Y19er?nku!UP{_HJ1^wtJtj7&52(mo_S0W2?Lxj=QTN zD1C1GVUfRHNZpsa$33*LS2?9KRo#pD^lG)Fa_z{otLMEjdR2Sg?U**mQD_5>ZFoii z03eVh3k1}@=y#vC2Upb%_qK|y9y=9H_OGeecR>!9En7C=%s+WR5qMj%el?TounGQI z(Lp04`R1{qp2VgL__%AIBEW$IiuCO!{sRis0k-lfS#uWnzh2M-LpZDqI|DpRE+~K$ zf}9K<%&cMbv+n*OLA&L~ln>MaCVHGwc{SJle-l)N!HiVmaHn&VNcZ=2LRJYZDhmM3 zOBHV9KkkD#;>@{wR1aUpcvxMf(a788<>rdnt7ZjbhgLuLEA@ivyF)(^MEeFQg?rFO zJv+FN_sHEBZ49bD#z2A*%`EZni*swgtg#V-%Y~Ll@M~sgU@*Ya0WBZ{wfs0}!;T*?B%=b#Aa>Q@(Ug<+5Os#3@QIDURj2jUfpA zO$#XZ0AXKn)zUuT&4xpad74UsR+u(vHK7g@9)x?>y3_#TqhW2iRwS?7k=m6RL6KT7iNdkb||>A+r002MHBm>U5vRxonH> z@ppuomxq=6K6VnEORKJ$d0T0|x)w?+0j@`x^h zef6tmmg0Ahdq3NDw7__oakc|-t+9jC?S&qo4J+j1_2Lr^6Vd3oV$giOc8R?_-5D$z z(@lc|snN90ce7pt{;3C@+-hbCQE`T~3v-4epw@KzmG?!CLm}*&cj5wb|L!d$HG@*h z(%8TE3GdePO}vV2(hCD6$%oEOp0Kn1De&0+rzkQhJY#vcwZ2U?#0AOnO^OB?hW9M7 z<;l(;rvYR;&gHkbch=^qKXuccZG>PxfcBYgRl^S4R`c0=2NB1Z+`5p-3kU1H%JU~% z0WFzh^Rq$Ydd(*H9xW$nu#|qZ?LIiJW10?mSF(%Nhiy9pKTI78Y3QmS3uB+_UTQpv zS$`irS=BI!xgGkwX2N@S^5#m<$Pd+-Gb1!}0~OfHZ)Wi7jVSE>fdl;TgigbSRRqo` zSu^%m$wo;1+m*m$f1gvHfRSkO3;S0g00|g5(1T`wu;J>!EMSni7Eg+Q$M5Oj)%lo0 zp>J)z5AieG)cY$tXMt4pq===>oCQRi_qIA3nGX249%agcPdzc}{s8cG#xX6+GwD{?Z&#GA@4B3|aXt_La1V(?VO9cX2R`BgbZoJa~7OUx1MJJ=$Xz=_Zo;Be|C<@OK_2pZxnSX^*H}0zmBaXph~g( zS0fugZIr=rO*Lxmam)yxVa>%6fyi7p6%d%tLm08fyZsa#2Az=r6mjR4f2Erric8t6 z8<>Mfy@x!Se$%Q7dg__Y>`~iO2JIzSlh&=bii>9XZ|JV&2R{j~f8L%+-pE6$gyxjk z>C4VRxSr8R$gLL8@;lN%WyNHJ6*cx!mK{58tpr75`j(0X(DRW$PdxHDgS~lbTIce6 z2>-^8{Jj5b(qi~kvzhJ6r^gAo))CIJMwrOUg{RF9;sS>anymF8O92{VZJ{t>o=ER~ zcvj{@ns=Wdw71%3#N~(QpCy|<^*R6rP*7u}VT-Rfl7hOsp_G@PGK1uki zE%yrcATuf*$F{uAaV^fMtPQO|ZxWU=`=$y1-a?>?Cp9yff|gS6xt*_0pv!_u;QJ+t zkL=yM74r!Cris(xqc{J)+rH)XbFZhb8{GxirqA8H==Q7;PIYH+M4l*QpJ@2Y#4@|t zxP6wB*&&)|8I1VC#97WolLvgPOD*Km*QFbqw>)(3r=&+LzUrAY<1=;i>li&L^SQx^ zGaCmZ`$6Mu!C5kDui|vea0AX4vz!IGND^+W1^FU{y<{uMlDL`bkm*jArJ!F!>XL+g zUAd>dJ#h*ORPzzuD@X3{b#IVrbVCCXO((Of{jG_2nrp2jL*&@`XnGImq!zAEJGz_Z zKpr%KIzfTXix!*^u<)cBuy*H5<^RmEW!Hi|q+!7QaA&P*F|SQsk#vnXO>C9dpN3a^Xs0TkVwCIB zK+<>H&V2=dY<88^&V3#1n(n#;L&WAxrVZj=9mO9CLn~O)BUrw#qUd@zw)LJgALUXfDfD-`Bv7 zzf*e^J*?e`q5e~rSPpgqYrAOs)EmV<@Sq>-Ag4!+@h)yFycmqEaRi-YciFJMS7>D8g3BmO(Ju^caZ4OJ(%xQtWOZ=pm91M6#Rnj?#bf->S@o^v$mC` zB843Yz5}IvYvpk0a4khN)%jZRJuln4t9iAdxqIAhxbaS$C{MS}E9B%eSgDa7r_n3P zA-~fWX+K#PoHnZR#jRamPZ0x$weh|>fDnEov zc4}H1DjhI+-(C0iE=7R;FM!CGKt&mRrAd0gX5$YYVO7RL;O_UY&HhzcH=Ng`xNWnt zKsI>cE*M#vl=?%}mRwL11)W%3Un7S3F$*54A<{R#I#?rda}H`EIi8gpGVsFIY-Fp7 zWrlAnya!OXzkYu}Q@`mpm97ofXNaN;X*FX({J|IrN4N+g#@70X`PHvDFK0u<3kF~H zG6Sgi{h*pv2w4veKrjT%W=?UAZ`c3T-gibd)otzC3%-aV2&llTs0ab+AR@#H(xrwT zMFP^LO9_ampfmyLB_f>=BE2UlolunC5h)QuKza!YckTB*<2!epJMK8&xp&+jcMSQV zVUxZ0+H1}7%(NjxY~2KKbeOaitDhu7!mHlQ<0;Ki0!Y+jNt{Seq- z!e%K)875xTh!>aI7~Op;J(O*lsg6mJTPWuz$yg^u8|B4;yoq#le6$ep%W}nJnD=s* zYE2gwh`yMMrBSH~A0*DlL{{6NF^9^$woD5h${0qoOjnT2)G!EI)-C!rR4db30r;o! z(3F8>$PHMDK|yunY`2v~eUO%+$7to}<+tsKW~hJ`)JOtx#U`0f}Y3#vNW+VFGG!~?7gXl8}q z6kQlMT0bsb$Dlw?m`lPkeUOQlcs#C3IZEMv`wiAf%XTs$9=69PerH>TxQlWdei7G{ z5EUNEC6V8;1ghI~$=8&P^2*I2!*dY?hs|o7ySE}OYM*~LB-$sg0!4H@el~tF+1H2Q zvAtwX_>uRpq7?eI&!$2##ivpUvT{C)tCQX44dH%Ays~LJDB6fVtBCa}&{Kp`W*_y} zxm~_>*HJLQ6SsvfBRl^N?b77OdmleWj~whwea0gs5Z#uv)Uq2#FHDqmQOwj#HX2Q8 zZHos^vIFe!yELwxCP7UmH=lB8Pk69c<$&eH`hH}ROFndw?HS^hc zcfcwF4kP_h-67e90X~LW#9nVN}!PsNXr5zztl12HX8mUI)XtkIfyyys}uK`Hxf z=#esRI6NTw#yAcYcf?K4iNr!#ec)$2X;cq!&*k?aTryX?Gc`Lw`l$${VP)FFpG8RpzftP>^t;zgY3OXIs-_plcAQQTl^hTqyj;_Fea3R19N&8riv_0VmvW zv|kZa*2+ew4B5n{gHmIwqHl^66fVZLhYRW5#*Q#Uke6VR#tQuBJf7v3FIW0x&|j+< z3q6vs>}U(*RZ{`v2yGpS@o`6=SzYax)f^qbpPKB!Bg-x}0L!&9<~`YEu{Es&tcQZ- z!8s6lr~xdP>6CU)MA->4n@!xM?JBVL?Sp+_Nt@@ec>S!GceJjXQ}u08Q$bpwG0gBR z2rQtSmn*=v3xJsJx=`#bt0d-|7TVVtrKu20-&i#182xms4m_P{jZQuxbGnKn7a}adTDb&pjafv8?^t(p&giK$0CAKei&07MFab09ufhmgt>2js4 zKRB-Ngjh)R2V(%4j(o5vSGdJM*A2-h1EQcTJfcQ!F(R-M9s~U^w@@hq8d!XiZ4~MM z;5)`CSuS+x8BDg+7s~a z_)|8K>vP4lbCbbhkkN$InBi77@jj@i9;8FX>O!{&NI0bzH?WrG5j)_sZa0d=C3w~8 zS3qG)0-0$DWw<2aN`K8v-ttfafPgT6I_qM?$nK({>Kdp?Wp7e01Ox%`M>~gSd#jLr zj>DHB)8aAC9d&dPZeWhJ-}qVn7&0vo;#6=7sM5Ss5V`4{#fyC1dhZyj z{s@>ZHK56a5NI-Se^QYo?1EH3(+fBOu^2ufjmSgHb6F^oUXk|7W$UrLRD3QT5Xv=3 zQiwz=gX#~t2sQ-%nnq+FB`+BiiMGE39klXIhx01uS|Oe&MCiR#I#fy`Bp1@xhatRb z_C54h%Qg(!f~<;C5P&4~s4<}9kry(t7i8rfEBwsmMFfxiHCxp8w>zaO({U{54xqyQ2$ z%&kc?F0Q0M*I3i(6}x_ONW27S`;`U^a~kkqE34FDxcyLD?iyj;$Tr61qVl*ezCVX( zXyDW^!zH$iTYd&|!fxBf`4wK*J;u_6JeRC(OMRk1{FNi5sfB}9&3uJjytfHHp_=A2 zc^OpbE?!h7683j}xD_C)|1H0CPSDe@Z2%CuDU(ZpTHEsdykn+)q8p&2rsWOzymAf` z(8+`n&n`RSSBu6~0|~OQKT@R8UW(zqSsJ@DiH-@vWt%FEA&D+aU$RmgT7^O8eHbMh zI&`K7j|pHIgM%{8JkVa_xk%F1p+-=y%V%$dPln}JUxnfc_u-5>69h9J?k$l42wPg~ z9N4I`0iP%+gpgIT48-NfN;pPsG`c+mO^LHx{~Gzc>YwdkOg1{^fzm z&P))TO)n+ZDaK^TJQpE=pgakH(in&s_K z2fc#l0(}Mb4IaI61u5$rD{A4(XAakhUF^I;TXF&%r`E2swH)JNat^UFW1NxDHSz0I zc>kFiFwrWLjiIgV4feyOE$J;$q=ywCh#~hPXwy(cC=B-bP2J~2m3}4-lD$CFI~O{$ z40$GTv6ZRa(*DmI!1pc7&-4=LWFw;=-JdkDC52m-iBo`n1D&qXf7X#S0QOC&1jDA? ztxhVmd$(2xh%d*i2vQ-$D>H~X&JRN`dPEr?H`4*h)kT7MrV+`uf_?OdsEiHg{$Hpv zUE9AqL4LB^?8*~8WMF{IWN+Maj6y2>!ue3k;L#tou^^B07qwPM`d@gkx7#T-7~MKf zgUiAQqEn`Z7V(sUw3N*VM6*@f{+T9VUG*6Oc>dp2QBI)BBE%EB%W+kdX9e0DUz!1x z9>-9>C}_|PYGrPwQ-0CEHuAgt34<7QPI!Lh(LDs+)BK|Pk^2m!g}aO|*T%lwxcSg< zM*hSeg@{^C18MYxg7Mx@2RvC0#GuCZ@BM!2=-;g@|Eq=NKU!J-J5p~8SwNKWWT+T4 zDG>DAtYi25(UOI&+DquEKD>`4zUU7aQCmwDM?)|^KW3r2w*)C${n3NJGRV+S!QBB5 zI=oZ;VfDI^aM5tS9H^%hI`uE7%2*Sf+)mYs*MWHvTH!eYDuj43iT~(-)EV%ta`8e#Gd^N$#R2%urlS%dTYcQqCzj%%ak-%j6 z3<_&J%=-rs{QtH~)Bmoo=znuje%~Hw-1Gm_7FoY1AI#qW($e;~=z&@Jch>N?;{8^< z-)0Arf&a8G;cuh++vxsl7xmvR=eNuGmlE`US5x@g>;RGZZ#RSeZFGMd-QPwR$iRP^ z_4tkR!KD5F5MY1b9j;pWc_r7!f0F2Z?y%v3*GCTAHekKC*U8n+t@)F{yK9F|9F08k zl6xP^Ev^$5I-EBT+&ggZXyTQsx8kqciURH%*eTkLcWa%9xcG8UA?E?LBXiX*Xiab){%dHy!Cg=3LO3O z_J4hRc;fGl-QZ}@KRA$&$h%(s^T0lGxo)$dL zX-k0Z*<_wPbL4wji0z+X2u};L9|x{KA>Rd`p`Y4bSn;D^apt1(w@QB}gTX;T)_*Sd zf3orim^jHZDP7zPi>ansVn5!#jj)y=+mOXI1ahps9(4U=jjig@*6z-me9}E}E7@i9 zenqRn5Y;rytEF8&Lk`}PRbTifsLlADN$%vylW$Zx(`zuk`ahgGNZDdhB-1SaxhG>U zrkovVDyL&bn{6Bsx*;Zc?yCqQSN!3UqHxpJ?NUrtel};t0F7J9vapl!q9RP6ja*=s z=YBl>LjK8)$K^y4-}I1~(~R)_KK5@4Gh-Fb?ZbVgD&Cz}!d7-l$`-+m(Cgl^)6>(G zpB+mIC~B|cv#+SLaO+CELN(?!Hsq^a2iO>T{D4bG0x zr<$)dRqZO3*>GSt{GzlbXgOEF_cD;mW)nJuOC-cB|K*+0gqUmJwXkFJ-J~yrhdR*6 zgAWMvt$avX`EuKHqaBch zoJemkt$+^dSIOq=m%(2MaknDNU*6g#$G|ussngdVjpHu%dTt;eCnMPCA#f*bBAu=Sy_&-hGT&nXFNF5V&#;4+_hz z)k&=#5$jZjIhtSq#Kfn=6b!Shw0!#Gq4W3d@_4%jl@PP|Lj@2-Cg~$`Hf0pGXmDTY4OkG9pdF(oGZey={KJ|866rL z-&*>zbC1=i%)NH)Y9LtGpGkE}I0oBjLCv}5=1L3H2AXwe4Vvk%=Suk>uHs8B-sWddj!somD>!ngIytQ7^|pRfpd+Jmvw2W-(K$P+2Kr% zC*1WI{IFCj8SIFuTvKyIT9XgQl~*-LYHX&AVo*+Sb5NsT0U{h*Xa?59wf#(Q1J60; zqd6IhjQ$U>^=Fm!iZHN5raV8Vl3uX_#m%$r%U(ED{0I2TY2tEqo>|prj|rx(bggm= zOO!Fj!^@q7StwV}A`tJTKb5q!oVjH^UK(wWdmEeRtwP-FGLM(BN^o0HnF!yno~UT{ z{`uB)WQ6B~#c|B}#jLzF*>HaOtxa}sv%}bKwEZ+_Pv6mk2s!9tmCbF;NM$_0W_GT= ztv+npZrPdialw7`YQsTCkwBGb!IqR7n$JGGr{~u8&Bj~S-*zm=Ux^g8<=I*H&%{zt zyY~s74jesI@FPhba2E#dWLCmq88gT@O1e zw6D+91HClGH6{w^sDjS&aru)ZyA`?Vyr@9;9SSUbmb%E|h`$<#3-k3hA#5&VgU48a zUG|0f>}v!D%gvWjVXg!_81q#Dc1z4UnRlHDs6VT)1e1d@D) zn!K_Y?USQNpNmL*%U&IGJH}J*0zSC_Wz`>Qau0Xy6qOR4N5`j6+K*?}s5u=CSgn+i zIbDTUZD2g9+#0@dXyII_jo*zCMQ0EoGEAb`4eY?$YtJ9C6`Q_|Ofo_k>YMzRT z^dVYh%|bH0VK6ocrGTEvGXZZN@WtD%e<*dS;=u9_4YwEL2RbkfBz^W-1Q?s^dTO1` zK=YCf@FLHtCS6kbc^$}2K75A?{Y|xm!j1)o8ddW-@l&_i2E5v zMGc1?)8xL^t?v^@(BXwUX*ao3(tJ11YsOo*sE&V%d1KRWm{^-HL4CPW#1?jStk5mX zsL-c`Ns_5lJ7Zimct(a4gOkg%#`fF4JIj}Nzhfe;PtwYhIxDWW%>+#I__xxUZRWC> zSpH9EfCUjUBpAtt>o9FRg8NZ(E+nBg$_UBC$~L%%hA(3a?u?}M-OEoqseVS;vhV!K zle2P9(=}xq6=ZP(=;+>@MNLX6ch&40&Gtg=`*A(2GZ=aFy$_y{6X{mCkAUgtBbLyS z#e?}l&Fjfku0EKH_S)jtbo5rauXPR0v{@m0pg*%^1|D%8SzkSF$yL;OiR4$Y9&5$s zHZ1KIb5tiqONl6NHe-LFF5OdfSMAd}&O>|kQ^e%zFRe-3*80tS`Q(>e>^}3GM@P3( z6^7DHw!YVKSFYSaua-4wY@Vw_&gEDl5lBD4Q4H7KP;vrb`2Z3wi6qhT?C(c(?1zAc64+N8;mTAq3s0M%;n2DasX51F*2qd zLnLU_OEUq>%)zBh4&M^@Sr4psmOstIs;z48xof|+D6;;Rr1|d4`(9wYiNpcpiv!Y* zw+f}3=GMwHY)Wzq0caSCpZh_7Um_^}vA~EZXgGdCTNU^r<@w$_2ZxkYY!ZCQyt~DH zELdJas}9D)akrC7VLq^S2f{a5!*tQ|uSFXVX2)edTOk=QJ~nJ!-FV+y3Jjh~S;~Hk zhOVt28irUFOY&w<=i6vX0~o}%Udwg>OBqs3-~lkiJumqc&7R1s-bz?Dp48v-S zZ>Ie2a_d@e?)HJ~~1WAZz>!H@C6kWS+2y-Y0#AF4{M}cA2w<-nH&+YU0dg_vY7PVHBH~mNYAvE z7^OI=s%5R;I=HpJg#?OW-Y74(?pT3|SDkCr^V-hy)GpJokFIozCNQ>bAdilw8e`Kx zqjBp$V+V9%gpkw`lbZlmX>l*u;{yMf$CXve9LU=s%1f*D)vU2lR`SE)(dYMj8)H_M zm)$mA`m^>6_vHov{BY58_Uv^L5hVX{AKlmB#2TJ5fJg3^+KpOMu>fa!{dzw3W3Bv> zJe4~+GC_Yvk`>${Ol1InLJ^%i z=-Uyi>{$djZhyP?%;G6s*QWJKKl5rcqF}{+-j?PY*>n9^7yyq8_WeeuMT{?Lb8ga? zvM_nu3@^XeF1;5nD@ds(+Is?_arz+#6ZKgz@!zieH54s*O-sbMps&K~D|pZ}vp5Mo zk5CHsdP5^+IWz4OsjandxYQf;H6pgwjaC_uXEqDM=U%no4N0DGy2?M_vv$A$(U+qUbH?LvEy=ZIWR#A(h{8TPWVD+b6ZR!b6s-Oy}RjJbx&K`+F}qK zZTcP+4*d{70%Djp=1p_cBl>3h+Ajrfx9}^Twua)|P4X#Ghp{~Cf^d>0irgHMGl}ao zHBZy)&DDE*Kv7lP^})xNGDmbE?fF_+vR3PR^ujCT^?AlXVTJVGeYDBuM~~JlR?D-{ zFJzC+bR3D}`Eo!g7m36*U-?!z@W~-w+@I&vpR&$V#+dI9fHwmiO_t5PSfCCdIO-Em zF~wv9JmTpXWqf_CzmM$X>9>zMc~db)FYCeuv^QKxDiVaTeUC-cVt3HNL2; zT&tWtDdJc-L1nu%+=oNxGxPb9;-g`b)@%;kkMG|X)+@4P#-P-RZ$qBs&GisIGd(nO z0MCS56~d)0#DM!jftMymfe%<;1c;p8%q|CAci&0ZbTF$kJDlNpPCm{hT{-P(Jf!UK z-PW!#JwZfFrJHT=`?PAt%a=Xc!bT$(GQ&$9H#jgOjUC@Dr(65q$Z9x+)Ju`yJ|Z3; zq|Ll`BfWRI=%n0V0vOH)eh621Fdp}k zfN|JOHb!l}ySr!ZEjTt)z3#Eoo+uUFNgTk=@BQ+y6g8lfnP3~UC6<~VQF&R@aq;EC zkX=>ziU>?j$qUYEh1(qn`do@T*ragAW3AHTJ^k>jhxy$M-BtN3CtOB9VZ5_ClcFCt zY32rc9oK~(9AKv9&g7!)=-Kd@${RjU;1OR|8n3;Jm_Ai4v&PEx&EVil8qV}tfM^SF z$zj~^99Gsq0pQ2$Ay{x@H*Nf8F4}cIavB>gFKVbY*O!9>t}cS5+hb1H*yAV07swg) zHlxDD`ObY|!m5fPqllpcM^aAbU%AD>Q%bUKv<>wm@ZGzS{q9a$9o zb)}mP1hc!d!eR;gm9~2Z-ycoGP0vpsE6op;!V`0o_})0$I|ciA&%Z=RhW|SBsBb(9 z<70C7t_?jkvr<6(_Hj${K#WigJFFiDhJ3fF5o@iJav};@t`gn4{cU1@bWn4wHz9J} zKFt+qy9!e`qF$a2X3c8{b2n4Iz<0)&Rt~SMk(%52HIXKsz023<9B_EOkijGVDcp-O zm=&DF5y-+@64t=Z&gZ>BMO>~?j24V8G2~xJ?O3K3OkTJk?zZjHx8~9l*RprGEytds z-wJGNXZh8xwElO_WKlE5w$U_*gi~q)b8~bWRp?%B(Lwk7dFaJ3K}AdWZeiiLm8l0q zYa@h`d-pES!q`25jHzG|V%K1x^Ay!X#?bB8n+=KRc+ZIxy%H7FY%Hh9g$t=}lx+3I zeC>{i`iPUEQ{Pi-=>iwN?6PD6UB4oad0v3N`vJWjZTWeG%VshWue|#?JbI3_0U}ov z(^J|h00!h0oU=8b*N2=bJ{;tu?|k50^(#I~l7G>U&0!^dE<%WuHQRzrdBAtwe4pgb zV?EWe6Ae0+7pL;bW=wHBjYoRM0L))&S#&0`9XO_#MH_;Y)FR8OOzKMo(5bDRaqsl< zodzft*gYR^=r$sk>RtO7rtH*T($4F^vOQ9DW`uLFUS&&53tEHE8i?nsF#q)Q%&qF@ zBDuY$Ms}LEu9R(r)4_;H^VRH&C`Eo%F*5Q)#Oh~pzN~gtBql{NbHF)TAB4UtbvJU% z8PThEU%-L>qe~|i4+lpI85byCNRVVZc~au-z|M-@u*- z0=73IGw?Vlc18Vqj#T7`odQlKDaz*>l0gVXy!$(`Sp>g9ZfU8a^=d`EW)nY8JT5xSIITY_eEP)FTVM! zB`?2}dg}pJL**_g413yg{n`10_mv@EahuW;Gdi~G)*;AFQ(e67_QdL7Fzp5_KX5|& zLdnQ<N84l0^SYb7VmGB_?)zNElBIEwfoOo}SG%4W+o&nL32{T3E z`?-kKt9(LvDeqS|eWMg7e37DkvxL&ZXM-w4zN6HA)HD#2qDyIwb7gt&+5DZ8*QxZU z5k75@5ERF|xPj<-`k}i#^>{IpY0jpFFXKgRWw6GX^Bi>)uftEIxG5yCpb#;C{ z9(e|WO%Pb^Dq9qE^KRNu)?Pmu&yMpf$}HTynQ7wEF4gDRU*ZxnPuo5V^DA)S7Et5U zDp6s#B^3F#1(`Uls{6@?>^JRK42{p*SnT{otLtd!sy5V7R#q=kdJ=Wp z+&l^wGrO#rY&yTye|^pYV>`9;N)<;8kR@wiWMOdnPLb3P-<81OAw0>^%Ij z$l%Nakx&qkO*o_Fk)8A0W#$E}+OL9iAHo*7LwSs4t4nm}%g^wH?U2oVSOX z>F4`hVw|T3O-W|MmBhr9NdtuZngnt-G$BfOi}m~;e~b_9obH{ENwoNRWZd-`6c?>g zdZWQNtMce4KsKkpp8uiHt6Tn17&Nn_2duiJW1lXFuyX(|*6q%WFwgAte3UBW*}I|M zV17)a;-h)GapzubV#@ALDhlN{_|0B8k%Klpyi!nBzkCR*YF9k8Ip$|P?Bb|Vrf4V} zCwC_qyx=Q=>{y>w#4vd!!aK)$7FP-1e6B~HIrhxr?qj_Y8+Kb=K~t)npJ+R3m{0zW;7Yyg&YAH!0}omkZG~4h_)TO>O@3Df_OuLUM;yd zy5SfmiYn)B=*D69wO-U;zQ`AxNWO5XkT?Q^i;O$cI2l93Q(eLiHOt~T%tn&Cmv|om zb;|hg-KRigS1`vn?3}!1`>`Gw>W*pu^2%~RtG`(Uu$fqpE&R6b%0#DKX7gs2Fj5*7 zp2_g&@N|c?OAo78GM5D=WqBYiAVZQ|k}RK5pTu#xwMXD_z?l9AZ-4pG`yfmPNkW(HlL1XJ%hG0YULtoD68$^!qHG+kCb!TjZ-^;4uC&PY$nw=GS7 z=neR`xwbZtO|#AFokcRz0R15yh-3Y)z4zvNc(Un?RW4ij`m!u(0iPcbAz6-3P;jf( z7UM zcIm!PzHslx%Er;$u0=mb+@#MNvrj{%4jGY$_~-JS(9u{?^}CBoKk_1oqg8ah>pN?Y zu%SPA=Iq+BS0Kk6r?JucJT)A^5w!^2`)ju;_wl}U5PpCXnZX=psq~k=B4Nz zzS?wb>BZ9Ai@`Y&+`{^xVvqb)!_}@NCV54$%Q1f4ZT@SDHeHE%&sF}FFGUYu0L@t**dou~yHUgfu6O515=?#8ln zJ^Eu$E#99f$+hP}L~G&?=w+ehtS7;~){fvnw^jMR^p-u(mqC&z+6|Wz#0I^-q&E8X z`@df{0EptSJ1R`z81!?q;-7n9e;@h%?|(c4wfNfI{l-~)#bD>$ji9rY?`ho4zy0vV F{{ZbD@TC9% diff --git a/docs/releases/v2.4.0.md b/docs/releases/v2.4.0.md index 4682fdd..826314e 100644 --- a/docs/releases/v2.4.0.md +++ b/docs/releases/v2.4.0.md @@ -9,7 +9,7 @@ Release date: 2026-07-15 - The portable Windows download now has one consistent name: `ColumnPadStudio.exe`. - Reorganised the main window, column behavior, save/load handling, Workflow Builder, workflow exports, and editor menus into focused files. - Fixed removed columns and pictures retaining old change-event connections. -- Kept existing text, Markdown, layout, workspace-session, recovery, and workflow file formats compatible. +- Kept existing text, layout, workspace-session, recovery, and workflow file formats compatible. ## Verification - Release build passes with no warnings or errors. diff --git a/docs/releases/v2.5.0.md b/docs/releases/v2.5.0.md new file mode 100644 index 0000000..48d3ff6 --- /dev/null +++ b/docs/releases/v2.5.0.md @@ -0,0 +1,42 @@ +# ColumnPad v2.5.0 + +Release date: 2026-08-07 + +## Download + +- `ColumnPadStudio.exe` - portable Windows x64 build. + +## Added + +- Standard 320 px, saved Custom 220-5000 px, and explicit Fit Columns to Window sizing. +- One global Snap All Columns Together control, adjustable column spacing, and a saved 32-160 px line-number gutter width. +- Theme-aware per-column text colours and Ruled, Soft Ruled, and Strong Ruled paper styles. + +## Improved + +- Adding columns now keeps fixed widths and shows the main horizontal scrollbar when the column strip exceeds the window. +- Each column keeps its own scrollbar for long text, while reset actions restore the current default width and unlock affected columns. +- Workflow Builder creation, linking, placement, save, import, and export controls are simpler and more predictable. +- Native layouts now carry picture data so saved workspaces remain portable. +- Recovery, session dirty tracking, file validation, and save-before-exit handling are more resilient. + +## Fixed + +- Restored readable text selection, consistent keyboard focus, correctly aligned paper lines and gutters, and repeated blank lines when pasting. +- Prevented text and JSON exports from silently losing pictures or rich column formatting. +- Added stable workflow node IDs and compatibility migration for older step-list workflows. + +## Changed + +- Replaced Markdown document/export support with concise readable JSON text exports. Native `.columnpad.json` remains the full-fidelity format. +- Updated the application, tests, and portable build to .NET 10 LTS. + +## Verification + +- Release build completed with 0 warnings and 0 errors. +- 51 domain checks and 558 app smoke checks passed. +- Published as one self-contained `ColumnPadStudio.exe` with no loose runtime files. + +## Known Note + +- The executable is not code-signed, so Windows SmartScreen may warn on first launch. diff --git a/docs/workflows/ColumnPadStudio-program-map.workflow.json b/docs/workflows/ColumnPadStudio-program-map.workflow.json index 29ca6eb..6f53eb6 100644 --- a/docs/workflows/ColumnPadStudio-program-map.workflow.json +++ b/docs/workflows/ColumnPadStudio-program-map.workflow.json @@ -122,17 +122,17 @@ "Id": "file-routing", "Kind": 2, "Title": "Choose file route", - "Description": "Opening or saving must decide whether the user is working with raw text, markdown, layout JSON, workspace-session JSON, or an export.", + "Description": "Opening or saving must decide whether the user is working with raw text, a concise JSON export, layout JSON, workspace-session JSON, or a workflow.", "Goal": "Send each file format through the right path without overwriting source files unexpectedly.", "Instructions": "Use FileWorkflowService, WorkspaceImportRules, MainWindow.FileSession.cs, MainViewModel.FileState.cs, and MainViewModel.Persistence.cs. Keep app-native files separate from human-readable exports.", "ExpectedOutput": "The app knows whether to load plain text, import an export, restore a layout, or restore multiple workspaces.", "ChecklistItems": [ { - "Text": "Plain .txt and .md files must not be auto-split unless they contain ColumnPad export markers.", + "Text": "Plain .txt files must not be auto-split unless they contain ColumnPad export markers.", "IsDone": false }, { - "Text": "Source text and markdown files require Save As before overwrite.", + "Text": "Source text files and concise JSON exports require Save As before overwrite.", "IsDone": false }, { @@ -164,7 +164,7 @@ "IsDone": false }, { - "Text": "Text and markdown exports stay human-readable and marked.", + "Text": "Text and JSON exports stay human-readable and marked.", "IsDone": false } ], @@ -192,7 +192,7 @@ "IsDone": false }, { - "Text": "Do not place image data inside plain .txt or .md exports.", + "Text": "Do not place image data inside plain .txt or concise JSON exports.", "IsDone": false } ], diff --git a/global.json b/global.json new file mode 100644 index 0000000..d46d21e --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} diff --git a/src/ColumnPadStudio.Domain/ColumnPadStudio.Domain.csproj b/src/ColumnPadStudio.Domain/ColumnPadStudio.Domain.csproj index ee2846b..bfb079a 100644 --- a/src/ColumnPadStudio.Domain/ColumnPadStudio.Domain.csproj +++ b/src/ColumnPadStudio.Domain/ColumnPadStudio.Domain.csproj @@ -1,7 +1,7 @@ - + - net8.0 + net10.0 enable enable diff --git a/src/ColumnPadStudio.Domain/Workspaces/WorkspaceColumnLayout.cs b/src/ColumnPadStudio.Domain/Workspaces/WorkspaceColumnLayout.cs new file mode 100644 index 0000000..5ac255b --- /dev/null +++ b/src/ColumnPadStudio.Domain/Workspaces/WorkspaceColumnLayout.cs @@ -0,0 +1,48 @@ +namespace ColumnPadStudio.Domain.Workspaces; + +public static class WorkspaceColumnLayout +{ + public static bool UsesFixedColumnStrip(int columnCount, bool fitColumnsToWindow) + => columnCount > 1 && !fitColumnsToWindow; + + public static double ResolveColumnWidth(int? widthPx, int defaultColumnWidthPx) + { + return widthPx is > 0 + ? WorkspaceConstraints.ClampColumnWidth(widthPx.Value) + : WorkspaceConstraints.ClampColumnWidth(defaultColumnWidthPx); + } + + public static double CalculateHostWidth( + IReadOnlyList columnWidths, + double viewportWidth, + int columnSpacingPx, + bool snapAllColumnsEnabled, + bool fitColumnsToWindow, + int defaultColumnWidthPx) + { + ArgumentNullException.ThrowIfNull(columnWidths); + + var safeViewportWidth = double.IsFinite(viewportWidth) + ? Math.Max(0, viewportWidth) + : 0; + + if (columnWidths.Count == 0) + return safeViewportWidth; + + if (columnWidths.Count == 1) + return safeViewportWidth; + + var spacingWidth = snapAllColumnsEnabled + ? (double)Math.Max(0, columnSpacingPx) * (columnWidths.Count - 1) + : 0; + + if (!UsesFixedColumnStrip(columnWidths.Count, fitColumnsToWindow)) + return Math.Max( + (WorkspaceConstraints.MinimumColumnWidth * columnWidths.Count) + spacingWidth, + safeViewportWidth); + + var contentWidth = columnWidths.Sum(widthPx => ResolveColumnWidth(widthPx, defaultColumnWidthPx)); + contentWidth += spacingWidth; + return Math.Max(contentWidth, safeViewportWidth); + } +} diff --git a/src/ColumnPadStudio.Domain/Workspaces/WorkspaceConstraints.cs b/src/ColumnPadStudio.Domain/Workspaces/WorkspaceConstraints.cs index 87d68f7..72124b7 100644 --- a/src/ColumnPadStudio.Domain/Workspaces/WorkspaceConstraints.cs +++ b/src/ColumnPadStudio.Domain/Workspaces/WorkspaceConstraints.cs @@ -4,7 +4,21 @@ public static class WorkspaceConstraints { public const int MinColumns = 1; public const int MaxColumns = 9999; + public const double MinimumColumnWidth = 220.0; + public const double DefaultColumnWidth = 320.0; + public const double MaximumColumnWidth = 5000.0; public static int ClampColumnCount(int requestedCount) => Math.Clamp(requestedCount, MinColumns, MaxColumns); + + public static double ClampColumnWidth(double requestedWidth) + { + if (double.IsNaN(requestedWidth) || double.IsInfinity(requestedWidth)) + return DefaultColumnWidth; + + return Math.Clamp(requestedWidth, MinimumColumnWidth, MaximumColumnWidth); + } + + public static int ClampColumnWidth(int requestedWidth) + => (int)ClampColumnWidth((double)requestedWidth); } diff --git a/src/ColumnPadStudio.Domain/Workspaces/WorkspaceImportRules.cs b/src/ColumnPadStudio.Domain/Workspaces/WorkspaceImportRules.cs index 1c36e84..995788d 100644 --- a/src/ColumnPadStudio.Domain/Workspaces/WorkspaceImportRules.cs +++ b/src/ColumnPadStudio.Domain/Workspaces/WorkspaceImportRules.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Text; using System.Text.Json; @@ -9,11 +10,14 @@ public static class WorkspaceImportRules { public const string TextExportMarker = "ColumnPad Export"; public const string TextExportFormatLine = "Format: Text"; - public const string MarkdownExportMarker = ""; + public const string TextExportVersionLine = "Version: 2"; + public const string JsonExportFileType = "ColumnPadTextExport"; + public const int CurrentJsonExportVersion = 1; + public const string WorkspaceSessionFileType = "ColumnPadWorkspaceSession"; + public const int CurrentWorkspaceSessionVersion = 2; private const string TextExportHeaderPrefix = "===== "; private const string TextExportHeaderSuffix = " ====="; - private const string MarkdownHeaderPrefix = "## "; public static bool IsWorkspaceSessionJson(string? json) { @@ -26,8 +30,30 @@ public static bool IsWorkspaceSessionJson(string? json) if (document.RootElement.ValueKind != JsonValueKind.Object) return false; - return document.RootElement.TryGetProperty("Workspaces", out var workspaces) && - workspaces.ValueKind == JsonValueKind.Array; + var root = document.RootElement; + if (!root.TryGetProperty("Version", out var versionNode) || + versionNode.ValueKind != JsonValueKind.Number || + !versionNode.TryGetInt32(out var version) || + version < 1 || + version > CurrentWorkspaceSessionVersion) + { + return false; + } + + var hasFileType = root.TryGetProperty("FileType", out var fileTypeNode); + if (hasFileType && + (fileTypeNode.ValueKind != JsonValueKind.String || + !string.Equals(fileTypeNode.GetString(), WorkspaceSessionFileType, StringComparison.Ordinal))) + { + return false; + } + + if (version >= CurrentWorkspaceSessionVersion && !hasFileType) + return false; + + return root.TryGetProperty("Workspaces", out var workspaces) && + workspaces.ValueKind == JsonValueKind.Array && + workspaces.GetArrayLength() > 0; } catch (JsonException) { @@ -44,19 +70,24 @@ public static bool LooksLikeTextExport(string? content) return lines.Take(4).Any(line => string.Equals(line.Trim(), TextExportMarker, StringComparison.Ordinal)); } - public static bool LooksLikeMarkdownExport(string? content) + public static bool IsJsonExport(string? json) { - if (string.IsNullOrWhiteSpace(content)) + try + { + _ = ParseJsonExportColumns(json); + return true; + } + catch (InvalidDataException) + { return false; - - var lines = NormalizeLineEndings(content).Split('\n'); - return lines.Take(4).Any(line => string.Equals(line.Trim(), MarkdownExportMarker, StringComparison.Ordinal)); + } } public static List ParseTextExportColumns(string? text) { var normalized = NormalizeLineEndings(text); - var lines = StripTextExportPreamble(normalized.Split('\n')); + var exportBody = StripTextExportPreamble(normalized.Split('\n')); + var lines = exportBody.Lines; var bodyFallback = string.Join('\n', lines); var parsed = new List(); @@ -75,6 +106,14 @@ void Flush() foreach (var line in lines) { + if (exportBody.UsesEscaping && TryUnescapeBodyLine(line, out var unescapedLine)) + { + currentTitle ??= "Column 1"; + skipInitialBlank = false; + body.Append(unescapedLine).Append('\n'); + continue; + } + if (TryParseTextExportHeader(line, out var title)) { Flush(); @@ -103,55 +142,57 @@ void Flush() return parsed; } - public static List ParseMarkdownExportColumns(string? markdown) + public static List ParseJsonExportColumns(string? json) { - var normalized = NormalizeLineEndings(markdown); - var lines = StripMarkdownExportPreamble(normalized.Split('\n')); - var bodyFallback = string.Join('\n', lines); - var parsed = new List(); - - string? currentTitle = null; - var body = new StringBuilder(); - var skipInitialBlank = false; - - void Flush() - { - if (currentTitle is null) - return; - - parsed.Add(new ImportedColumn(currentTitle, body.ToString().TrimEnd('\n'))); - body.Clear(); - } + if (string.IsNullOrWhiteSpace(json)) + throw new InvalidDataException("The JSON export is empty."); - foreach (var line in lines) + try { - if (line.StartsWith(MarkdownHeaderPrefix, StringComparison.Ordinal)) + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("FileType", out var fileTypeNode) || + fileTypeNode.ValueKind != JsonValueKind.String || + !string.Equals(fileTypeNode.GetString(), JsonExportFileType, StringComparison.Ordinal) || + !root.TryGetProperty("Version", out var versionNode) || + versionNode.ValueKind != JsonValueKind.Number || + !versionNode.TryGetInt32(out var version) || + version < 1 || + version > CurrentJsonExportVersion || + !root.TryGetProperty("Columns", out var columnsNode) || + columnsNode.ValueKind != JsonValueKind.Array) { - Flush(); - var heading = line[MarkdownHeaderPrefix.Length..]; - currentTitle = string.IsNullOrWhiteSpace(heading) ? $"Column {parsed.Count + 1}" : heading.Trim(); - skipInitialBlank = true; - continue; + throw new InvalidDataException("This is not a supported ColumnPad text export."); } - currentTitle ??= "Column 1"; - - if (skipInitialBlank && line.Length == 0) + var columns = new List(columnsNode.GetArrayLength()); + foreach (var columnNode in columnsNode.EnumerateArray()) { - skipInitialBlank = false; - continue; + if (columnNode.ValueKind != JsonValueKind.Object || + !columnNode.TryGetProperty("Title", out var titleNode) || + titleNode.ValueKind != JsonValueKind.String || + !columnNode.TryGetProperty("Text", out var textNode) || + textNode.ValueKind != JsonValueKind.String) + { + throw new InvalidDataException("A ColumnPad text export contains an invalid column."); + } + + columns.Add(new ImportedColumn(titleNode.GetString() ?? string.Empty, textNode.GetString() ?? string.Empty)); } - skipInitialBlank = false; - body.Append(line).Append('\n'); + return columns; } + catch (JsonException ex) + { + throw new InvalidDataException("The JSON export could not be read.", ex); + } + } - Flush(); - - if (parsed.Count == 0) - parsed.Add(new ImportedColumn("Column 1", bodyFallback.TrimEnd('\n'))); - - return parsed; + public static string EscapeTextExportBody(string? text) + { + return EscapeExportBody(text, line => line.StartsWith('\\') || + TryParseTextExportHeader(line, out _)); } private static string NormalizeLineEndings(string? value) @@ -161,31 +202,30 @@ private static string NormalizeLineEndings(string? value) .Replace('\r', '\n'); } - private static string[] StripTextExportPreamble(string[] lines) + private static ExportBody StripTextExportPreamble(string[] lines) { if (lines.Length == 0 || !string.Equals(lines[0].Trim(), TextExportMarker, StringComparison.Ordinal)) - return lines; + return new ExportBody(lines, UsesEscaping: false); var index = 1; if (index < lines.Length && string.Equals(lines[index].Trim(), TextExportFormatLine, StringComparison.Ordinal)) index++; - while (index < lines.Length && lines[index].Length == 0) + var usesEscaping = false; + if (index < lines.Length && string.Equals(lines[index].Trim(), TextExportVersionLine, StringComparison.Ordinal)) + { + usesEscaping = true; index++; + } + else if (index < lines.Length && lines[index].TrimStart().StartsWith("Version:", StringComparison.Ordinal)) + { + throw new InvalidDataException("This text export was created by a newer version of ColumnPad."); + } - return lines[index..]; - } - - private static string[] StripMarkdownExportPreamble(string[] lines) - { - if (lines.Length == 0 || !string.Equals(lines[0].Trim(), MarkdownExportMarker, StringComparison.Ordinal)) - return lines; - - var index = 1; while (index < lines.Length && lines[index].Length == 0) index++; - return lines[index..]; + return new ExportBody(lines[index..], usesEscaping); } private static bool TryParseTextExportHeader(string line, out string title) @@ -201,4 +241,30 @@ private static bool TryParseTextExportHeader(string line, out string title) title = string.Empty; return false; } + + private static string EscapeExportBody(string? text, Func shouldEscape) + { + var lines = NormalizeLineEndings(text).Split('\n'); + for (var index = 0; index < lines.Length; index++) + { + if (shouldEscape(lines[index])) + lines[index] = "\\" + lines[index]; + } + + return string.Join(Environment.NewLine, lines); + } + + private static bool TryUnescapeBodyLine(string line, out string unescaped) + { + if (line.StartsWith('\\')) + { + unescaped = line[1..]; + return true; + } + + unescaped = line; + return false; + } + + private readonly record struct ExportBody(string[] Lines, bool UsesEscaping); } diff --git a/src/ColumnPadStudio/App.xaml.cs b/src/ColumnPadStudio/App.xaml.cs index 9a768e6..eb4f36f 100644 --- a/src/ColumnPadStudio/App.xaml.cs +++ b/src/ColumnPadStudio/App.xaml.cs @@ -1,4 +1,5 @@ using ColumnPadStudio.Services; +using System.Globalization; using System.IO; using System.Text; using System.Threading.Tasks; @@ -9,6 +10,8 @@ namespace ColumnPadStudio; public partial class App : Application { + private const long MaximumCrashLogBytes = 2 * 1024 * 1024; + protected override void OnStartup(StartupEventArgs e) { DispatcherUnhandledException += OnDispatcherUnhandledException; @@ -20,6 +23,7 @@ protected override void OnStartup(StartupEventArgs e) private static void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { + PreserveRecoveryForCrash(); var logPath = WriteCrashLog(e.Exception); MessageBox.Show( "ColumnPad hit an unexpected error and needs to close.\n\nCrash details were saved here:\n" + logPath, @@ -33,6 +37,7 @@ private static void OnDispatcherUnhandledException(object sender, DispatcherUnha private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) { + PreserveRecoveryForCrash(); if (e.ExceptionObject is Exception exception) { WriteCrashLog(exception); @@ -48,15 +53,21 @@ private static void OnUnobservedTaskException(object? sender, UnobservedTaskExce e.SetObserved(); } + private static void PreserveRecoveryForCrash() + { + if (Current?.MainWindow is MainWindow mainWindow) + mainWindow.PreserveRecoveryForAbnormalShutdown(); + } + private static string WriteCrashLog(Exception exception) { - var details = new StringBuilder() - .AppendLine($"Timestamp: {DateTimeOffset.Now:O}") - .AppendLine($"App Version: {typeof(App).Assembly.GetName().Version}") - .AppendLine() - .AppendLine(exception.ToString()) - .AppendLine(new string('-', 80)) - .ToString(); + var detailsBuilder = new StringBuilder(); + detailsBuilder.AppendLine(CultureInfo.InvariantCulture, $"Timestamp: {DateTimeOffset.Now:O}"); + detailsBuilder.AppendLine(CultureInfo.InvariantCulture, $"App Version: {typeof(App).Assembly.GetName().Version}"); + detailsBuilder.AppendLine(); + detailsBuilder.AppendLine(exception.ToString()); + detailsBuilder.AppendLine(new string('-', 80)); + var details = detailsBuilder.ToString(); Exception? lastWriteError = null; foreach (var logPath in GetCrashLogCandidates()) @@ -67,7 +78,8 @@ private static string WriteCrashLog(Exception exception) if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); - File.AppendAllText(logPath, details); + RotateCrashLogIfNeeded(logPath, Encoding.UTF8.GetByteCount(details)); + File.AppendAllText(logPath, details, Encoding.UTF8); return logPath; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) @@ -84,4 +96,15 @@ private static IEnumerable GetCrashLogCandidates() yield return Path.Combine(AppStoragePaths.CrashLogsDirectory, "crash.log"); yield return Path.Combine(Path.GetTempPath(), "ColumnPadStudio", "crash.log"); } + + private static void RotateCrashLogIfNeeded(string logPath, int pendingBytes) + { + if (!File.Exists(logPath) || new FileInfo(logPath).Length + pendingBytes <= MaximumCrashLogBytes) + return; + + var previousLogPath = Path.Combine( + Path.GetDirectoryName(logPath) ?? Path.GetTempPath(), + $"{Path.GetFileNameWithoutExtension(logPath)}.previous{Path.GetExtension(logPath)}"); + File.Move(logPath, previousLogPath, overwrite: true); + } } diff --git a/src/ColumnPadStudio/ColumnPadStudio.csproj b/src/ColumnPadStudio/ColumnPadStudio.csproj index c50e712..efb8371 100644 --- a/src/ColumnPadStudio/ColumnPadStudio.csproj +++ b/src/ColumnPadStudio/ColumnPadStudio.csproj @@ -4,18 +4,22 @@ + + + + WinExe - net8.0-windows + net10.0-windows true ColumnPadStudio enable enable Assets\ColumnNotepad.ico - 2.4.1 - 2.4.1.0 - 2.4.1.0 - 2.4.1 + 2.5.0 + 2.5.0.0 + 2.5.0.0 + 2.5.0 diff --git a/src/ColumnPadStudio/Controls/ColumnEditorControl.Gutter.cs b/src/ColumnPadStudio/Controls/ColumnEditorControl.Gutter.cs index 9a63d6a..bbbb17f 100644 --- a/src/ColumnPadStudio/Controls/ColumnEditorControl.Gutter.cs +++ b/src/ColumnPadStudio/Controls/ColumnEditorControl.Gutter.cs @@ -10,20 +10,19 @@ private void LineNumbers_PreviewMouseLeftButtonDown(object sender, MouseButtonEv { EditorFocused?.Invoke(this, EventArgs.Empty); - var lineIndex = GetLineIndexFromGutterPoint(e.GetPosition(LineNumberGutter)); - if (lineIndex < 0) + var visualLineIndex = GetLineIndexFromGutterPoint(e.GetPosition(LineNumberGutter)); + if (visualLineIndex < 0) return; - _gutterContextLineIndex = lineIndex; + _gutterContextLineIndex = visualLineIndex; if (VM?.LineMarkerMode == LineMarkerMode.Checklist) { - VM.ToggleChecklistLineChecked(lineIndex); - QueueLineNumberRefresh(); + ToggleChecklistCheckAtVisualLine(visualLineIndex); e.Handled = true; return; } - MoveCaretToLineStart(lineIndex); + MoveCaretToLineStart(visualLineIndex); e.Handled = true; } @@ -83,14 +82,116 @@ private void LineMarkerToggleCheck_Click(object sender, RoutedEventArgs e) if (VM.LineMarkerMode != LineMarkerMode.Checklist) VM.LineMarkerMode = LineMarkerMode.Checklist; - var targetLine = _gutterContextLineIndex >= 0 - ? _gutterContextLineIndex - : Editor.GetLineIndexFromCharacterIndex(Editor.CaretIndex); + if (_gutterContextLineIndex >= 0) + { + ToggleChecklistCheckAtVisualLine(_gutterContextLineIndex); + return; + } - VM.ToggleChecklistLineChecked(targetLine); + VM.ToggleChecklistLineChecked(GetLogicalLineIndexFromCharacterIndex(Editor.CaretIndex)); QueueLineNumberRefresh(); } + private void ToggleChecklistCheckAtVisualLine(int visualLineIndex) + { + if (VM is null || visualLineIndex < 0) + return; + + VM.ToggleChecklistLineChecked(GetLogicalLineIndexFromVisualLineIndex(visualLineIndex)); + QueueLineNumberRefresh(); + } + + private int GetLogicalLineIndexFromVisualLineIndex(int visualLineIndex) + { + if (Editor.LineCount <= 0) + return 0; + + var safeVisualLine = Math.Clamp(visualLineIndex, 0, Editor.LineCount - 1); + return BuildVisualToLogicalLineMap(Editor.LineCount)[safeVisualLine]; + } + + private int GetLogicalLineIndexFromCharacterIndex(int characterIndex) + { + var text = Editor.Text ?? string.Empty; + var safeCharacterIndex = Math.Clamp(characterIndex, 0, text.Length); + var logicalLineIndex = 0; + + for (var index = 0; index < safeCharacterIndex; index++) + { + if (text[index] == '\r') + { + logicalLineIndex++; + if (index + 1 < safeCharacterIndex && text[index + 1] == '\n') + index++; + } + else if (text[index] == '\n') + { + logicalLineIndex++; + } + } + + return logicalLineIndex; + } + + private int[] BuildVisualToLogicalLineMap(int visualLineCount) + { + var safeVisualLineCount = Math.Max(1, visualLineCount); + if (Editor.LineCount <= 0) + return new int[safeVisualLineCount]; + + var logicalLineStarts = new List { 0 }; + var text = Editor.Text ?? string.Empty; + + for (var index = 0; index < text.Length; index++) + { + if (text[index] == '\r') + { + if (index + 1 < text.Length && text[index + 1] == '\n') + index++; + + logicalLineStarts.Add(index + 1); + } + else if (text[index] == '\n') + { + logicalLineStarts.Add(index + 1); + } + } + + var logicalVisualLineStarts = logicalLineStarts + .Select(characterIndex => Editor.GetLineIndexFromCharacterIndex(characterIndex)) + .Select(visualLineIndex => Math.Clamp(visualLineIndex, 0, safeVisualLineCount - 1)) + .ToArray(); + + var visualToLogical = new int[safeVisualLineCount]; + var logicalLineIndex = 0; + for (var visualLineIndex = 0; visualLineIndex < safeVisualLineCount; visualLineIndex++) + { + while (logicalLineIndex + 1 < logicalVisualLineStarts.Length + && logicalVisualLineStarts[logicalLineIndex + 1] <= visualLineIndex) + { + logicalLineIndex++; + } + + visualToLogical[visualLineIndex] = logicalLineIndex; + } + + return visualToLogical; + } + + private static bool IsLogicalLineStart(string text, int characterIndex) + { + var safeCharacterIndex = Math.Clamp(characterIndex, 0, text.Length); + if (safeCharacterIndex == 0) + return true; + + var previous = text[safeCharacterIndex - 1]; + if (previous == '\n') + return true; + + return previous == '\r' + && (safeCharacterIndex >= text.Length || text[safeCharacterIndex] != '\n'); + } + private void SetLineMarkerMode(LineMarkerMode markerMode) { if (VM is null) diff --git a/src/ColumnPadStudio/Controls/ColumnEditorControl.Images.cs b/src/ColumnPadStudio/Controls/ColumnEditorControl.Images.cs index 0abe58c..b46968f 100644 --- a/src/ColumnPadStudio/Controls/ColumnEditorControl.Images.cs +++ b/src/ColumnPadStudio/Controls/ColumnEditorControl.Images.cs @@ -9,9 +9,10 @@ namespace ColumnPadStudio.Controls; public partial class ColumnEditorControl { private ColumnImageViewModel? _resizingImage; - private Point _imageResizeStartPointer; private double _imageResizeStartWidth; private double _imageResizeAspectRatio = 4.0 / 3.0; + private double _imageResizeHorizontalChange; + private double _imageResizeVerticalChange; private void InsertPicture_Click(object sender, RoutedEventArgs e) { @@ -98,9 +99,10 @@ private void ImageResizeThumb_DragStarted(object sender, DragStartedEventArgs e) VM.SelectImage(image); _resizingImage = image; - _imageResizeStartPointer = Mouse.GetPosition(ImageOverlay); _imageResizeStartWidth = image.Width; _imageResizeAspectRatio = GetImageAspectRatio(image); + _imageResizeHorizontalChange = 0; + _imageResizeVerticalChange = 0; EditorFocused?.Invoke(this, EventArgs.Empty); e.Handled = true; } @@ -110,11 +112,10 @@ private void ImageResizeThumb_DragDelta(object sender, DragDeltaEventArgs e) if (GetTaggedImage(sender) is not { } image || !ReferenceEquals(_resizingImage, image)) return; - var pointer = Mouse.GetPosition(ImageOverlay); - var horizontalChange = pointer.X - _imageResizeStartPointer.X; - var verticalChange = pointer.Y - _imageResizeStartPointer.Y; + _imageResizeHorizontalChange += e.HorizontalChange; + _imageResizeVerticalChange += e.VerticalChange; var heightPerWidth = 1.0 / _imageResizeAspectRatio; - var requestedChange = (horizontalChange + (verticalChange * heightPerWidth)) + var requestedChange = (_imageResizeHorizontalChange + (_imageResizeVerticalChange * heightPerWidth)) / (1.0 + (heightPerWidth * heightPerWidth)); var maxWidthFromSurface = Math.Max( @@ -166,20 +167,6 @@ private void ImageSelectFromMenu_Click(object sender, RoutedEventArgs e) EditorFocused?.Invoke(this, EventArgs.Empty); } - private void ClampImagesToSurface() - { - if (VM is null || ImageOverlay.ActualWidth <= 0 || ImageOverlay.ActualHeight <= 0) - return; - - foreach (var image in VM.Images) - { - var maxWidth = Math.Max(ColumnImageViewModel.MinDisplayWidth, ImageOverlay.ActualWidth - image.Left); - image.Width = Math.Min(image.Width, maxWidth); - image.Left = Math.Min(image.Left, Math.Max(0.0, ImageOverlay.ActualWidth - image.Width)); - image.Top = Math.Min(image.Top, Math.Max(0.0, ImageOverlay.ActualHeight - image.Height)); - } - } - private static ColumnImageViewModel? GetTaggedImage(object sender) => (sender as FrameworkElement)?.Tag as ColumnImageViewModel; diff --git a/src/ColumnPadStudio/Controls/ColumnEditorControl.Lifecycle.cs b/src/ColumnPadStudio/Controls/ColumnEditorControl.Lifecycle.cs index a20aac9..90ee93f 100644 --- a/src/ColumnPadStudio/Controls/ColumnEditorControl.Lifecycle.cs +++ b/src/ColumnPadStudio/Controls/ColumnEditorControl.Lifecycle.cs @@ -13,22 +13,19 @@ public partial class ColumnEditorControl { private void ColumnEditorControl_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e) { - if (_observedVm is not null) - _observedVm.PropertyChanged -= ObservedVm_PropertyChanged; - - _observedVm = e.NewValue as ColumnViewModel; - if (_observedVm is not null) - _observedVm.PropertyChanged += ObservedVm_PropertyChanged; + SetObservedViewModel(e.NewValue as ColumnViewModel); _lastRenderedLineNumberCount = -1; + _lastRenderedLineMarkerMode = null; + _lastRenderedGutterStateVersion = -1; + _lastRenderedChecklistLayoutVersion = -1; QueueLineNumberRefresh(); SyncLineNumberScrollWithEditor(); } private void ObservedVm_PropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is nameof(ColumnViewModel.LineMarkerMode) - or nameof(ColumnViewModel.ChecklistDone) + if (e.PropertyName is nameof(ColumnViewModel.GutterStateVersion) or nameof(ColumnViewModel.ShowLineNumbers) or nameof(ColumnViewModel.WordWrap) or nameof(ColumnViewModel.EditorFontFamily) @@ -36,7 +33,7 @@ or nameof(ColumnViewModel.EditorFontSize) or nameof(ColumnViewModel.EditorFontStyle) or nameof(ColumnViewModel.EditorFontWeight)) { - _lastRenderedLineNumberCount = -1; + _checklistLayoutVersion++; QueueLineNumberRefresh(); } } @@ -49,7 +46,9 @@ private void Editor_GotFocus(object sender, RoutedEventArgs e) private void ColumnEditorControl_Loaded(object sender, RoutedEventArgs e) { + SetObservedViewModel(VM); AttachEditorScrollViewer(); + QueueEditorScrollRestore(); QueueLineNumberRefresh(); SyncLineNumberScrollWithEditor(); } @@ -57,20 +56,42 @@ private void ColumnEditorControl_Loaded(object sender, RoutedEventArgs e) private void ColumnEditorControl_Unloaded(object sender, RoutedEventArgs e) { DetachEditorScrollViewer(); + DetachObservedViewModel(); + } + + private void SetObservedViewModel(ColumnViewModel? viewModel) + { + if (!ReferenceEquals(_observedVm, viewModel)) + { + DetachObservedViewModel(); + _observedVm = viewModel; + } + + if (!IsLoaded || _observedVm is null || _isObservedVmSubscribed) + return; + + _observedVm.PropertyChanged += ObservedVm_PropertyChanged; + _isObservedVmSubscribed = true; + } - if (_observedVm is not null) + private void DetachObservedViewModel() + { + if (_isObservedVmSubscribed && _observedVm is not null) _observedVm.PropertyChanged -= ObservedVm_PropertyChanged; + + _isObservedVmSubscribed = false; } private void Editor_TextChanged(object sender, TextChangedEventArgs e) { + _checklistLayoutVersion++; QueueLineNumberRefresh(); SyncLineNumberScrollWithEditor(); } private void Editor_SizeChanged(object sender, SizeChangedEventArgs e) { - ClampImagesToSurface(); + _checklistLayoutVersion++; QueueLineNumberRefresh(); SyncLineNumberScrollWithEditor(); } @@ -85,6 +106,7 @@ private void AttachEditorScrollViewer() return; _editorScrollViewer.ScrollChanged += EditorScrollViewer_ScrollChanged; + QueueEditorScrollRestore(); } private void DetachEditorScrollViewer() @@ -92,10 +114,41 @@ private void DetachEditorScrollViewer() if (_editorScrollViewer is null) return; + if (!_hasSavedEditorScrollOffsets) + { + _savedEditorHorizontalOffset = _editorScrollViewer.HorizontalOffset; + _savedEditorVerticalOffset = _editorScrollViewer.VerticalOffset; + _hasSavedEditorScrollOffsets = true; + } + _editorScrollViewer.ScrollChanged -= EditorScrollViewer_ScrollChanged; _editorScrollViewer = null; } + private void QueueEditorScrollRestore() + { + if (!_hasSavedEditorScrollOffsets + || _editorScrollRestorePending + || _editorScrollViewer is null) + { + return; + } + + _editorScrollRestorePending = true; + var scrollViewer = _editorScrollViewer; + Dispatcher.BeginInvoke(new Action(() => + { + _editorScrollRestorePending = false; + if (!IsLoaded || !ReferenceEquals(scrollViewer, _editorScrollViewer)) + return; + + scrollViewer.ScrollToHorizontalOffset(_savedEditorHorizontalOffset); + scrollViewer.ScrollToVerticalOffset(_savedEditorVerticalOffset); + _hasSavedEditorScrollOffsets = false; + SyncLineNumberScroll(scrollViewer.VerticalOffset); + }), DispatcherPriority.Loaded); + } + private void EditorScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e) { if (e.VerticalChange == 0 && e.ExtentHeightChange == 0) @@ -115,7 +168,10 @@ private void SyncLineNumberScrollWithEditor() private void SyncLineNumberScroll(double verticalOffset) { - LineNumbersTransform.Y = -Math.Max(0, verticalOffset); + var safeVerticalOffset = double.IsFinite(verticalOffset) ? Math.Max(0, verticalOffset) : 0; + LineNumbersTransform.Y = -safeVerticalOffset; + EditorPaperBackground.VerticalOffset = safeVerticalOffset; + LineNumberPaperBackground.VerticalOffset = safeVerticalOffset; } private static T? FindDescendant(DependencyObject parent) where T : DependencyObject @@ -152,40 +208,60 @@ private void RefreshVisibleLineNumbers() { var lineCount = Math.Max(1, Editor.LineCount); var markerMode = VM?.LineMarkerMode ?? LineMarkerMode.Numbers; + var gutterStateVersion = VM?.GutterStateVersion ?? 0; + VM?.SetVisibleLineCount(lineCount); + + if (lineCount == _lastRenderedLineNumberCount + && markerMode == _lastRenderedLineMarkerMode + && gutterStateVersion == _lastRenderedGutterStateVersion + && (markerMode != LineMarkerMode.Checklist + || _checklistLayoutVersion == _lastRenderedChecklistLayoutVersion)) + { + SyncLineNumberScrollWithEditor(); + return; + } var lineBreak = Environment.NewLine; var sb = new StringBuilder(lineCount * (lineBreak.Length + 3)); + var visualToLogicalLines = markerMode == LineMarkerMode.Checklist + ? BuildVisualToLogicalLineMap(lineCount) + : null; for (var lineIndex = 0; lineIndex < lineCount; lineIndex++) { if (lineIndex > 0) sb.Append(lineBreak); - sb.Append(GetLineNumberLabel(markerMode, lineIndex)); + sb.Append(GetLineNumberLabel(markerMode, lineIndex, visualToLogicalLines)); } var renderedLineNumbers = sb.ToString(); - VM?.SetVisibleLineCount(lineCount); - - if (lineCount == _lastRenderedLineNumberCount && - string.Equals(LineNumbers.Text, renderedLineNumbers, StringComparison.Ordinal)) - { - SyncLineNumberScrollWithEditor(); - return; - } - LineNumbers.Text = renderedLineNumbers; _lastRenderedLineNumberCount = lineCount; + _lastRenderedLineMarkerMode = markerMode; + _lastRenderedGutterStateVersion = gutterStateVersion; + _lastRenderedChecklistLayoutVersion = _checklistLayoutVersion; SyncLineNumberScrollWithEditor(); } - private string GetLineNumberLabel(LineMarkerMode markerMode, int lineIndex) + private string GetLineNumberLabel( + LineMarkerMode markerMode, + int visualLineIndex, + IReadOnlyList? visualToLogicalLines) { if (markerMode == LineMarkerMode.Bullets) return "\u2022"; if (markerMode == LineMarkerMode.Checklist) - return VM?.IsChecklistLineChecked(lineIndex) == true ? "\u2611" : "\u2610"; + { + var logicalLineIndex = visualToLogicalLines?[visualLineIndex] ?? visualLineIndex; + var isContinuationRow = visualLineIndex > 0 + && visualToLogicalLines?[visualLineIndex - 1] == logicalLineIndex; + if (isContinuationRow) + return string.Empty; + + return VM?.IsChecklistLineChecked(logicalLineIndex) == true ? "\u2611" : "\u2610"; + } - return (lineIndex + 1).ToString(CultureInfo.InvariantCulture); + return (visualLineIndex + 1).ToString(CultureInfo.InvariantCulture); } } diff --git a/src/ColumnPadStudio/Controls/ColumnEditorControl.Menus.cs b/src/ColumnPadStudio/Controls/ColumnEditorControl.Menus.cs index 7c2ba74..c22c277 100644 --- a/src/ColumnPadStudio/Controls/ColumnEditorControl.Menus.cs +++ b/src/ColumnPadStudio/Controls/ColumnEditorControl.Menus.cs @@ -1,4 +1,5 @@ using ColumnPadStudio.Domain.Lists; +using ColumnPadStudio.Services; using ColumnPadStudio.ViewModels; using System.Linq; using System.Windows; @@ -9,15 +10,35 @@ namespace ColumnPadStudio.Controls; public partial class ColumnEditorControl { - private void ColumnContextMenu_Opened(object sender, RoutedEventArgs e) + private void HeaderGrip_ContextMenuOpening(object sender, ContextMenuEventArgs e) { - UpdatePastePresetMenuChecks(); + ActivateColumnForActions(); + } + + private void ColumnActionsButton_Click(object sender, RoutedEventArgs e) + { + ActivateColumnForActions(); + + if (ColumnActionsButton.ContextMenu is not { } columnContextMenu) + return; + columnContextMenu.PlacementTarget = ColumnActionsButton; + columnContextMenu.IsOpen = true; + } + + private void ActivateColumnForActions() + { + ColumnActionsOpening?.Invoke(this, EventArgs.Empty); + } + + private void ColumnContextMenu_Opened(object sender, RoutedEventArgs e) + { if (VM is null) return; ColumnFontBoldMenuItem.IsChecked = VM.EditorFontWeight == FontWeights.Bold; ColumnFontItalicMenuItem.IsChecked = VM.EditorFontStyle == FontStyles.Italic; + RefreshTextColorMenuChecks(); RefreshPicturesMenu(); } @@ -92,6 +113,36 @@ private void ColumnFontReset_Click(object sender, RoutedEventArgs e) ResetFontRequested?.Invoke(this, EventArgs.Empty); } + private void ColumnTextColorPreset_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem { Tag: string value }) + SetTextColorRequested?.Invoke(this, new ColumnTextColorEventArgs(value)); + } + + private void ColumnTextColorCustom_Click(object sender, RoutedEventArgs e) + { + SetCustomTextColorRequested?.Invoke(this, EventArgs.Empty); + } + + private void RefreshTextColorMenuChecks() + { + if (VM is null) + return; + + foreach (var item in ColumnTextColorMenuItem.Items.OfType()) + { + if (item.Tag is string value) + item.IsChecked = string.Equals(value, VM.EditorTextColor, StringComparison.Ordinal); + } + + var isCustom = ColumnTextColorService.IsCustom(VM.EditorTextColor); + ColumnTextColorCustomMenuItem.IsChecked = isCustom; + ColumnTextColorCustomSwatch.Background = VM.CustomEditorTextColorBrush ?? Brushes.Transparent; + ColumnTextColorCustomLabel.Text = isCustom + ? $"Custom... ({VM.EditorTextColor})" + : "Custom..."; + } + private void PastePresetNone_Click(object sender, RoutedEventArgs e) => SetPastePreset(PasteListPreset.None); private void PastePresetBullets_Click(object sender, RoutedEventArgs e) => SetPastePreset(PasteListPreset.Bullets); private void PastePresetChecklist_Click(object sender, RoutedEventArgs e) => SetPastePreset(PasteListPreset.Checklist); @@ -108,24 +159,25 @@ private void ToggleChecklistChecksForSelection() if (VM.LineMarkerMode != LineMarkerMode.Checklist) VM.LineMarkerMode = LineMarkerMode.Checklist; - var (startLine, endLine) = GetSelectedLineRange(); + var (startLine, endLine) = GetSelectedLogicalLineRange(); for (var i = startLine; i <= endLine; i++) VM.ToggleChecklistLineChecked(i); QueueLineNumberRefresh(); } - private (int StartLine, int EndLine) GetSelectedLineRange() + private (int StartLine, int EndLine) GetSelectedLogicalLineRange() { var selectionStart = Editor.SelectionStart; var selectionEnd = selectionStart + Editor.SelectionLength; + var editorText = Editor.Text ?? string.Empty; - var startLine = Editor.GetLineIndexFromCharacterIndex(selectionStart); - var endLine = Editor.GetLineIndexFromCharacterIndex(selectionEnd); + var startLine = GetLogicalLineIndexFromCharacterIndex(selectionStart); + var endLine = GetLogicalLineIndexFromCharacterIndex(selectionEnd); if (selectionEnd > selectionStart && endLine > startLine && - selectionEnd == Editor.GetCharacterIndexFromLineIndex(endLine)) + IsLogicalLineStart(editorText, selectionEnd)) { endLine--; } diff --git a/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml b/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml index 975bac4..1bfee9f 100644 --- a/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml +++ b/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml @@ -2,10 +2,20 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:ColumnPadStudio.Controls" - MinWidth="220" + xmlns:domain="clr-namespace:ColumnPadStudio.Domain.Workspaces;assembly=ColumnPadStudio.Domain" + MinWidth="{x:Static domain:WorkspaceConstraints.MinimumColumnWidth}" Foreground="{DynamicResource ControlForegroundBrush}" UseLayoutRounding="True" SnapsToDevicePixels="True"> + + + @@ -19,6 +29,7 @@ - + - - - - - - - - - - - - - - - - - - - @@ -87,29 +64,141 @@ Foreground="{DynamicResource LineNumberForegroundBrush}"/> - - - - - @@ -125,25 +214,26 @@ + + - - - @@ -171,20 +261,15 @@ - - - - - + @@ -299,8 +412,8 @@ - - + + @@ -432,6 +545,10 @@ + + + + diff --git a/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml.cs b/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml.cs index f752e2b..56370f1 100644 --- a/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml.cs +++ b/src/ColumnPadStudio/Controls/ColumnEditorControl.xaml.cs @@ -20,9 +20,15 @@ public sealed class ColumnImageFileEventArgs(string filePath, double left, doubl public double Top { get; } = top; } +public sealed class ColumnTextColorEventArgs(string value) : EventArgs +{ + public string Value { get; } = value; +} + public partial class ColumnEditorControl : UserControl { public event EventHandler? EditorFocused; + public event EventHandler? ColumnActionsOpening; public event EventHandler? LockWidthRequested; public event EventHandler? MoveLeftRequested; public event EventHandler? MoveRightRequested; @@ -39,13 +45,24 @@ public partial class ColumnEditorControl : UserControl public event EventHandler? ToggleBoldRequested; public event EventHandler? ToggleItalicRequested; public event EventHandler? ResetFontRequested; + public event EventHandler? SetTextColorRequested; + public event EventHandler? SetCustomTextColorRequested; private ScrollViewer? _editorScrollViewer; private bool _lineNumberRefreshPending; private int _lastRenderedLineNumberCount = -1; + private LineMarkerMode? _lastRenderedLineMarkerMode; + private int _lastRenderedGutterStateVersion = -1; + private int _checklistLayoutVersion; + private int _lastRenderedChecklistLayoutVersion = -1; private int _gutterContextLineIndex = -1; private int _editorContextMenuCharacterIndex = -1; private ColumnViewModel? _observedVm; + private bool _isObservedVmSubscribed; + private bool _hasSavedEditorScrollOffsets; + private bool _editorScrollRestorePending; + private double _savedEditorHorizontalOffset; + private double _savedEditorVerticalOffset; public ColumnEditorControl() { @@ -58,7 +75,6 @@ public ColumnEditorControl() public int SelectionStart => Editor.SelectionStart; public int SelectionLength => Editor.SelectionLength; public double PictureSurfaceWidth => ImageOverlay.ActualWidth; - public double PictureSurfaceHeight => ImageOverlay.ActualHeight; private ColumnViewModel? VM => DataContext as ColumnViewModel; diff --git a/src/ColumnPadStudio/Controls/PaperBackground.cs b/src/ColumnPadStudio/Controls/PaperBackground.cs new file mode 100644 index 0000000..a73f53e --- /dev/null +++ b/src/ColumnPadStudio/Controls/PaperBackground.cs @@ -0,0 +1,112 @@ +using ColumnPadStudio.Models; +using System.Windows; +using System.Windows.Media; + +namespace ColumnPadStudio.Controls; + +public sealed class PaperBackground : FrameworkElement +{ + public static readonly DependencyProperty BaseBackgroundProperty = DependencyProperty.Register( + nameof(BaseBackground), + typeof(Brush), + typeof(PaperBackground), + new FrameworkPropertyMetadata(Brushes.Transparent, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty PatternBrushProperty = DependencyProperty.Register( + nameof(PatternBrush), + typeof(Brush), + typeof(PaperBackground), + new FrameworkPropertyMetadata(Brushes.Transparent, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty IsPaperEnabledProperty = DependencyProperty.Register( + nameof(IsPaperEnabled), + typeof(bool), + typeof(PaperBackground), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty PaperStyleProperty = DependencyProperty.Register( + nameof(PaperStyle), + typeof(PaperStyle), + typeof(PaperBackground), + new FrameworkPropertyMetadata(PaperStyle.Ruled, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty LineHeightProperty = DependencyProperty.Register( + nameof(LineHeight), + typeof(double), + typeof(PaperBackground), + new FrameworkPropertyMetadata(23.0, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty VerticalOffsetProperty = DependencyProperty.Register( + nameof(VerticalOffset), + typeof(double), + typeof(PaperBackground), + new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender)); + + public Brush BaseBackground + { + get => (Brush)GetValue(BaseBackgroundProperty); + set => SetValue(BaseBackgroundProperty, value); + } + + public Brush PatternBrush + { + get => (Brush)GetValue(PatternBrushProperty); + set => SetValue(PatternBrushProperty, value); + } + + public bool IsPaperEnabled + { + get => (bool)GetValue(IsPaperEnabledProperty); + set => SetValue(IsPaperEnabledProperty, value); + } + + public PaperStyle PaperStyle + { + get => (PaperStyle)GetValue(PaperStyleProperty); + set => SetValue(PaperStyleProperty, value); + } + + public double LineHeight + { + get => (double)GetValue(LineHeightProperty); + set => SetValue(LineHeightProperty, value); + } + + public double VerticalOffset + { + get => (double)GetValue(VerticalOffsetProperty); + set => SetValue(VerticalOffsetProperty, value); + } + + protected override void OnRender(DrawingContext drawingContext) + { + base.OnRender(drawingContext); + drawingContext.DrawRectangle(BaseBackground, null, new Rect(RenderSize)); + + if (!IsPaperEnabled || ActualWidth <= 0 || ActualHeight <= 0) + return; + + var spacing = double.IsFinite(LineHeight) ? Math.Max(8.0, LineHeight) : 23.0; + var style = Enum.IsDefined(PaperStyle) ? PaperStyle : PaperStyle.Ruled; + var verticalOffset = double.IsFinite(VerticalOffset) ? Math.Max(0.0, VerticalOffset) : 0.0; + var pen = new Pen(PatternBrush, style == PaperStyle.StrongRuled ? 2.0 : 1.0); + + if (style == PaperStyle.SoftRuled) + drawingContext.PushOpacity(0.55); + + DrawRuledLines(drawingContext, pen, spacing, verticalOffset); + + if (style == PaperStyle.SoftRuled) + drawingContext.Pop(); + } + + private void DrawRuledLines(DrawingContext drawingContext, Pen pen, double spacing, double verticalOffset) + { + var firstLineY = spacing - (verticalOffset % spacing) - 0.5; + if (firstLineY < 0) + firstLineY += spacing; + + for (var y = firstLineY; y < ActualHeight; y += spacing) + drawingContext.DrawLine(pen, new Point(0, y), new Point(ActualWidth, y)); + } +} diff --git a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.Canvas.cs b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.Canvas.cs index 63ece81..21b4437 100644 --- a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.Canvas.cs +++ b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.Canvas.cs @@ -12,6 +12,21 @@ public partial class WorkflowBuilderWindow private double _dragStartX; private double _dragStartY; + private void AddNodeMenuButton_Click(object sender, RoutedEventArgs e) + { + if (sender is Button button) + OpenButtonContextMenu(button); + } + + private static void OpenButtonContextMenu(Button button) + { + if (button.ContextMenu is not { } menu) + return; + + menu.PlacementTarget = button; + menu.IsOpen = true; + } + private void AddNodeOfKind_Click(object sender, RoutedEventArgs e) { if (sender is not FrameworkElement { Tag: string kindName } || @@ -21,10 +36,14 @@ private void AddNodeOfKind_Click(object sender, RoutedEventArgs e) } ViewModel.AddNode(kind); + ShowNodeInspector(); } private void DuplicateNode_Click(object sender, RoutedEventArgs e) - => ViewModel.DuplicateSelectedNode(); + { + if (ViewModel.DuplicateSelectedNode()) + ShowNodeInspector(); + } private void RemoveNode_Click(object sender, RoutedEventArgs e) => ViewModel.RemoveSelectedNode(); @@ -33,7 +52,10 @@ private void AutoLayout_Click(object sender, RoutedEventArgs e) => ViewModel.AutoLayoutSelectedWorkflow(); private void AddLink_Click(object sender, RoutedEventArgs e) - => ViewModel.AddLink(); + { + if (ViewModel.AddLink()) + InspectorTabs.SelectedItem = ConnectionsInspectorTab; + } private void RemoveLink_Click(object sender, RoutedEventArgs e) => ViewModel.RemoveSelectedLink(); @@ -65,6 +87,7 @@ private void WorkflowNode_MouseLeftButtonDown(object sender, MouseButtonEventArg return; ViewModel.SelectedNode = node; + ShowNodeInspector(); _draggedNode = node; _dragStartPoint = e.GetPosition(WorkflowDiagramSurface); _dragStartX = node.X; @@ -105,6 +128,10 @@ private void WorkflowNodeColor_Click(object sender, RoutedEventArgs e) node.Color = color; ViewModel.SelectedNode = node; + ShowNodeInspector(); e.Handled = true; } + + private void ShowNodeInspector() + => InspectorTabs.SelectedItem = NodeInspectorTab; } diff --git a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.FileActions.cs b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.FileActions.cs index cfc5038..fefaaf3 100644 --- a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.FileActions.cs +++ b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.FileActions.cs @@ -60,7 +60,7 @@ private void ImportWorkflowJson_Click(object sender, RoutedEventArgs e) { var dialog = new OpenFileDialog { - Filter = "Workflow JSON (*.workflow.json;*.json)|*.workflow.json;*.json|All files (*.*)|*.*", + Filter = "Reloadable workflow JSON (*.workflow.json;*.json)|*.workflow.json;*.json|All files (*.*)|*.*", FilterIndex = 1 }; @@ -112,8 +112,8 @@ private void ExportWorkflowJson_Click(object sender, RoutedEventArgs e) { ExportWorkflow( ".workflow.json", - "Workflow JSON (*.workflow.json)|*.workflow.json|JSON (*.json)|*.json|All files (*.*)|*.*", - "workflow JSON", + "Reloadable workflow JSON (*.workflow.json)|*.workflow.json|JSON (*.json)|*.json|All files (*.*)|*.*", + "reloadable workflow JSON", ViewModel.ExportSelectedWorkflowToFile); } @@ -121,20 +121,11 @@ private void ExportWorkflowText_Click(object sender, RoutedEventArgs e) { ExportWorkflow( ".workflow.txt", - "Workflow text (*.workflow.txt)|*.workflow.txt|Text (*.txt)|*.txt|All files (*.*)|*.*", - "workflow text", + "Readable workflow text (*.workflow.txt)|*.workflow.txt|Text (*.txt)|*.txt|All files (*.*)|*.*", + "readable text copy", ViewModel.ExportSelectedWorkflowTextToFile); } - private void ExportWorkflowMarkdown_Click(object sender, RoutedEventArgs e) - { - ExportWorkflow( - ".workflow.md", - "Workflow markdown (*.workflow.md)|*.workflow.md|Markdown (*.md)|*.md|All files (*.*)|*.*", - "workflow markdown", - ViewModel.ExportSelectedWorkflowMarkdownToFile); - } - private void ExportWorkflow( string defaultExtension, string filter, diff --git a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.xaml b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.xaml index a7d9439..fd82f2d 100644 --- a/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.xaml +++ b/src/ColumnPadStudio/Controls/WorkflowBuilderWindow.xaml @@ -163,8 +163,8 @@ - @@ -194,20 +193,10 @@ - -