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)

-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

-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 94cc9e1..77c3013 100644
Binary files a/docs/columnpad-screenshot.png and b/docs/columnpad-screenshot.png differ
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
@@ -35,9 +35,15 @@
-
-
-
+
+
+
+
+
+
+
@@ -53,10 +59,26 @@
IsCheckable="True"
IsChecked="{Binding ActiveVm.ShowLineNumbers, Mode=TwoWay}"
InputGestureText="Alt+L"/>
-
+
+
+
+
+
+
+
@@ -131,10 +153,6 @@
-
-
-
-
@@ -147,14 +165,49 @@
InputGestureText="Alt+Shift+Right"
IsEnabled="{Binding ActiveVm.CanMoveActiveColumnRight}"/>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/src/ColumnPadStudio/MainWindow.xaml.cs b/src/ColumnPadStudio/MainWindow.xaml.cs
index c9414c5..b561f78 100644
--- a/src/ColumnPadStudio/MainWindow.xaml.cs
+++ b/src/ColumnPadStudio/MainWindow.xaml.cs
@@ -1,8 +1,10 @@
using ColumnPadStudio.Controls;
+using ColumnPadStudio.Domain.Workspaces;
using ColumnPadStudio.Models;
using ColumnPadStudio.Services;
using System.Collections.ObjectModel;
using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Windows;
using System.Windows.Threading;
@@ -10,9 +12,15 @@
namespace ColumnPadStudio;
+[SuppressMessage(
+ "Design",
+ "CA1001:Types that own disposable fields should be disposable",
+ Justification = "WPF owns the window lifetime; recovery cancellation resources are disposed by its verified clean-close path.")]
public partial class MainWindow : Window, INotifyPropertyChanged
{
private readonly DispatcherTimer _autoSaveTimer = new() { Interval = TimeSpan.FromSeconds(25) };
+ private readonly object _recoveryLifecycleGate = new();
+ private readonly LatestWriteCoordinator _autoRecoveryWriter;
private WorkspaceSession? _activeWorkspace;
private string _lastFindText = string.Empty;
@@ -22,11 +30,44 @@ public partial class MainWindow : Window, INotifyPropertyChanged
private WorkflowBuilderWindow? _workflowBuilderWindow;
private AppPreferences _appPreferences;
private bool _autoRecoveryWarningShown;
+ private RecoveryLifecycleState _recoveryLifecycleState;
+ private CancellationTokenSource? _recoveryClearCancellation;
+ private bool _closeAttemptInProgress;
+ private bool _allowCloseAfterRecoveryShutdown;
public event PropertyChangedEventHandler? PropertyChanged;
public ObservableCollection Workspaces { get; } = new();
+ public bool SnapAllColumnsEnabled
+ {
+ get => _appPreferences.SnapAllColumnsEnabled;
+ set => UpdateSnapAllColumns(value);
+ }
+
+ public bool FitColumnsToWindow
+ {
+ get => _appPreferences.FitColumnsToWindow;
+ set => UpdateFitColumnsToWindow(value);
+ }
+
+ public bool IsStandardColumnWidthSelected =>
+ !FitColumnsToWindow &&
+ _appPreferences.DefaultColumnWidthPx == (int)WorkspaceConstraints.DefaultColumnWidth;
+
+ public bool IsCustomColumnWidthSelected =>
+ !FitColumnsToWindow && !IsStandardColumnWidthSelected;
+
+ public bool CanManageColumnWidths =>
+ !FitColumnsToWindow && (_activeWorkspace?.Vm.Columns.Count ?? 0) > 1;
+
+ public string CustomColumnWidthMenuHeader =>
+ _appPreferences.DefaultColumnWidthPx == (int)WorkspaceConstraints.DefaultColumnWidth
+ ? "_Custom..."
+ : $"_Custom... ({_appPreferences.DefaultColumnWidthPx} px)";
+
+ public string ColumnSpacingMenuHeader => $"Column Gap... ({_appPreferences.ColumnSpacingPx} px)";
+
public WorkspaceSession? ActiveWorkspace
{
get => _activeWorkspace;
@@ -83,8 +124,11 @@ public MainViewModel ActiveVm
public MainWindow()
{
+ _autoRecoveryWriter = new LatestWriteCoordinator(
+ WriteCapturedRecoveryAsync,
+ ReportAutoRecoveryWriteResult);
InitializeComponent();
- _appPreferences = AppPreferencesService.Load();
+ _appPreferences = AppPreferencesService.Load(out var preferencesWarning);
ApplyTheme(_appPreferences.ThemePreset);
WorkspaceRenameMenuItem.Click += WorkspaceRename_Click;
WorkspaceAddMenuItem.Click += WorkspaceAdd_Click;
@@ -95,6 +139,8 @@ public MainWindow()
InitializeDefaultWorkspace();
DataContext = this;
+ if (!string.IsNullOrWhiteSpace(preferencesWarning))
+ ActiveVm.StatusText = preferencesWarning;
_autoSaveTimer.Tick += AutoSaveTimer_Tick;
_autoSaveTimer.Start();
@@ -143,14 +189,7 @@ private void SyncThemePreference(string preset, MainViewModel? sourceVm)
if (!string.Equals(_appPreferences.ThemePreset, normalized, StringComparison.Ordinal))
{
_appPreferences = _appPreferences with { ThemePreset = normalized };
- try
- {
- AppPreferencesService.Save(_appPreferences);
- }
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
- {
- // Keep the current session theme even if preferences cannot be persisted right now.
- }
+ PersistAppPreferences();
}
foreach (var workspace in Workspaces)
@@ -163,6 +202,19 @@ private void SyncThemePreference(string preset, MainViewModel? sourceVm)
}
}
+ private void PersistAppPreferences()
+ {
+ try
+ {
+ AppPreferencesService.Save(_appPreferences);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ if (Workspaces.Count > 0)
+ ActiveVm.StatusText = "Settings changed for this session, but could not be saved for the next launch.";
+ }
+ }
+
private void RaisePropertyChanged(string name)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
diff --git a/src/ColumnPadStudio/Models/AppPreferences.cs b/src/ColumnPadStudio/Models/AppPreferences.cs
index fb9fd34..a81de0e 100644
--- a/src/ColumnPadStudio/Models/AppPreferences.cs
+++ b/src/ColumnPadStudio/Models/AppPreferences.cs
@@ -1,3 +1,26 @@
+using ColumnPadStudio.Domain.Workspaces;
+
namespace ColumnPadStudio.Models;
-public sealed record AppPreferences(string ThemePreset = "Default Mode");
+public sealed record AppPreferences(
+ string ThemePreset = "Default Mode",
+ bool SnapAllColumnsEnabled = true,
+ int ColumnSpacingPx = 4,
+ bool FitColumnsToWindow = false,
+ int DefaultColumnWidthPx = (int)WorkspaceConstraints.DefaultColumnWidth)
+{
+ public const int MinimumColumnSpacingPx = 0;
+ public const int MaximumColumnSpacingPx = 200;
+ public const int DefaultColumnSpacingPx = 4;
+ public const int StandardColumnWidthPx = (int)WorkspaceConstraints.DefaultColumnWidth;
+
+ public static int NormalizeColumnSpacing(int value)
+ {
+ return Math.Clamp(value, MinimumColumnSpacingPx, MaximumColumnSpacingPx);
+ }
+
+ public static int NormalizeDefaultColumnWidth(int value)
+ {
+ return WorkspaceConstraints.ClampColumnWidth(value);
+ }
+}
diff --git a/src/ColumnPadStudio/Models/PaperStyle.cs b/src/ColumnPadStudio/Models/PaperStyle.cs
new file mode 100644
index 0000000..9d36120
--- /dev/null
+++ b/src/ColumnPadStudio/Models/PaperStyle.cs
@@ -0,0 +1,8 @@
+namespace ColumnPadStudio.Models;
+
+public enum PaperStyle
+{
+ Ruled,
+ SoftRuled,
+ StrongRuled
+}
diff --git a/src/ColumnPadStudio/Models/SaveFileKind.cs b/src/ColumnPadStudio/Models/SaveFileKind.cs
index 33ea795..6f33185 100644
--- a/src/ColumnPadStudio/Models/SaveFileKind.cs
+++ b/src/ColumnPadStudio/Models/SaveFileKind.cs
@@ -4,7 +4,6 @@ public enum SaveFileKind
{
Layout,
TextDocument,
- MarkdownDocument,
TextExport,
- MarkdownExport
+ JsonExport
}
diff --git a/src/ColumnPadStudio/Properties/PublishProfiles/FolderProfile.pubxml b/src/ColumnPadStudio/Properties/PublishProfiles/FolderProfile.pubxml
index 20b07c4..a7fc595 100644
--- a/src/ColumnPadStudio/Properties/PublishProfiles/FolderProfile.pubxml
+++ b/src/ColumnPadStudio/Properties/PublishProfiles/FolderProfile.pubxml
@@ -7,10 +7,9 @@
$(MSBuildProjectDirectory)\publish\
FileSystem
<_TargetId>Folder
- net8.0-windows
+ net10.0-windows
win-x64
- 8.0.25
- false
+ true
true
true
true
diff --git a/src/ColumnPadStudio/Resources/AppResources.xaml b/src/ColumnPadStudio/Resources/AppResources.xaml
index 92441f9..a63b138 100644
--- a/src/ColumnPadStudio/Resources/AppResources.xaml
+++ b/src/ColumnPadStudio/Resources/AppResources.xaml
@@ -1,7 +1,8 @@
+ 2
-
+
diff --git a/src/ColumnPadStudio/Resources/ControlStyles.xaml b/src/ColumnPadStudio/Resources/ControlStyles.xaml
index 69353f0..38d1f11 100644
--- a/src/ColumnPadStudio/Resources/ControlStyles.xaml
+++ b/src/ColumnPadStudio/Resources/ControlStyles.xaml
@@ -33,7 +33,7 @@
-
+
@@ -149,10 +149,10 @@
-
+
-
+
@@ -268,6 +268,9 @@
+
+
+
@@ -409,8 +412,8 @@
-
-
+
+
@@ -461,11 +464,6 @@
-
-
-
-
-
diff --git a/src/ColumnPadStudio/Resources/Themes/DarkTheme.xaml b/src/ColumnPadStudio/Resources/Themes/DarkTheme.xaml
new file mode 100644
index 0000000..8fb9d97
--- /dev/null
+++ b/src/ColumnPadStudio/Resources/Themes/DarkTheme.xaml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ColumnPadStudio/Resources/Themes/DefaultTheme.xaml b/src/ColumnPadStudio/Resources/Themes/DefaultTheme.xaml
new file mode 100644
index 0000000..4f020b9
--- /dev/null
+++ b/src/ColumnPadStudio/Resources/Themes/DefaultTheme.xaml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ColumnPadStudio/Resources/ThemeBrushes.xaml b/src/ColumnPadStudio/Resources/Themes/LightTheme.xaml
similarity index 72%
rename from src/ColumnPadStudio/Resources/ThemeBrushes.xaml
rename to src/ColumnPadStudio/Resources/Themes/LightTheme.xaml
index 4c137c6..7b35b0f 100644
--- a/src/ColumnPadStudio/Resources/ThemeBrushes.xaml
+++ b/src/ColumnPadStudio/Resources/Themes/LightTheme.xaml
@@ -1,7 +1,5 @@
- 2
-
@@ -32,47 +30,25 @@
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
+
+
-
-
diff --git a/src/ColumnPadStudio/Services/AppPreferencesService.cs b/src/ColumnPadStudio/Services/AppPreferencesService.cs
index ee71305..088187a 100644
--- a/src/ColumnPadStudio/Services/AppPreferencesService.cs
+++ b/src/ColumnPadStudio/Services/AppPreferencesService.cs
@@ -11,7 +11,11 @@ public static class AppPreferencesService
public static string PreferencesPath => Path.Combine(AppStoragePaths.RootDirectory, "app-preferences.json");
public static AppPreferences Load(string? path = null)
+ => Load(out _, path);
+
+ public static AppPreferences Load(out string? warning, string? path = null)
{
+ warning = null;
var resolvedPath = ResolvePath(path);
if (!File.Exists(resolvedPath))
return new AppPreferences();
@@ -19,13 +23,25 @@ public static AppPreferences Load(string? path = null)
try
{
var json = File.ReadAllText(resolvedPath);
- var loaded = JsonSerializer.Deserialize(json);
- return loaded is null
- ? new AppPreferences()
- : new AppPreferences(ThemePresetService.Normalize(loaded.ThemePreset));
+ using var document = JsonDocument.Parse(json);
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ throw new JsonException("The preferences file did not contain an object.");
+
+ var loaded = JsonSerializer.Deserialize(json)
+ ?? throw new JsonException("The preferences file did not contain an object.");
+ return NormalizeLoadedPreferences(loaded, document.RootElement);
+ }
+ catch (JsonException)
+ {
+ var invalidPath = TryQuarantineInvalidFile(resolvedPath);
+ warning = invalidPath is null
+ ? "Preferences could not be read. Default settings are in use."
+ : $"Preferences could not be read. Defaults are in use and the invalid file was kept as {Path.GetFileName(invalidPath)}.";
+ return new AppPreferences();
}
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
+ warning = "Preferences could not be read. Default settings are in use.";
return new AppPreferences();
}
}
@@ -35,12 +51,77 @@ public static void Save(AppPreferences preferences, string? path = null)
ArgumentNullException.ThrowIfNull(preferences);
var resolvedPath = ResolvePath(path);
- var normalized = preferences with { ThemePreset = ThemePresetService.Normalize(preferences.ThemePreset) };
+ var normalized = Normalize(preferences);
AtomicFileWriter.WriteText(resolvedPath, JsonSerializer.Serialize(normalized, JsonOptions));
}
+ private static AppPreferences Normalize(AppPreferences preferences)
+ {
+ return preferences with
+ {
+ ThemePreset = ThemePresetService.Normalize(preferences.ThemePreset),
+ ColumnSpacingPx = AppPreferences.NormalizeColumnSpacing(preferences.ColumnSpacingPx),
+ DefaultColumnWidthPx = AppPreferences.NormalizeDefaultColumnWidth(preferences.DefaultColumnWidthPx)
+ };
+ }
+
+ private static AppPreferences NormalizeLoadedPreferences(
+ AppPreferences preferences,
+ JsonElement root)
+ {
+ var hasSnapPreference = HasProperty(root, nameof(AppPreferences.SnapAllColumnsEnabled));
+ var hasSpacingPreference = HasProperty(root, nameof(AppPreferences.ColumnSpacingPx));
+ var hasFitPreference = HasProperty(root, nameof(AppPreferences.FitColumnsToWindow));
+ var hasDefaultWidthPreference = HasProperty(root, nameof(AppPreferences.DefaultColumnWidthPx));
+
+ var snapAllColumnsEnabled = hasSnapPreference
+ ? preferences.SnapAllColumnsEnabled
+ : true;
+
+ var fitColumnsToWindow = hasFitPreference
+ ? preferences.FitColumnsToWindow
+ : false;
+
+ return Normalize(preferences with
+ {
+ SnapAllColumnsEnabled = snapAllColumnsEnabled,
+ ColumnSpacingPx = hasSpacingPreference
+ ? preferences.ColumnSpacingPx
+ : AppPreferences.DefaultColumnSpacingPx,
+ FitColumnsToWindow = fitColumnsToWindow,
+ DefaultColumnWidthPx = hasDefaultWidthPreference
+ ? preferences.DefaultColumnWidthPx
+ : AppPreferences.StandardColumnWidthPx
+ });
+ }
+
+ private static bool HasProperty(JsonElement root, string propertyName)
+ {
+ foreach (var property in root.EnumerateObject())
+ {
+ if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+
+ return false;
+ }
+
private static string ResolvePath(string? path)
{
return string.IsNullOrWhiteSpace(path) ? PreferencesPath : path;
}
+
+ private static string? TryQuarantineInvalidFile(string path)
+ {
+ var invalidPath = $"{path}.invalid-{DateTime.UtcNow:yyyyMMdd-HHmmssfff}";
+ try
+ {
+ File.Move(path, invalidPath);
+ return invalidPath;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
}
diff --git a/src/ColumnPadStudio/Services/AppStoragePaths.cs b/src/ColumnPadStudio/Services/AppStoragePaths.cs
index 4ae437b..96f4b3a 100644
--- a/src/ColumnPadStudio/Services/AppStoragePaths.cs
+++ b/src/ColumnPadStudio/Services/AppStoragePaths.cs
@@ -12,7 +12,5 @@ public static class AppStoragePaths
public static string WorkflowsDirectory => Path.Combine(RootDirectory, "Workflows");
- public static string ImagesDirectory => Path.Combine(RootDirectory, "Images");
-
public static string CrashLogsDirectory => Path.Combine(RootDirectory, "CrashLogs");
}
diff --git a/src/ColumnPadStudio/Services/ClipboardTextService.cs b/src/ColumnPadStudio/Services/ClipboardTextService.cs
index 95106a5..975727e 100644
--- a/src/ColumnPadStudio/Services/ClipboardTextService.cs
+++ b/src/ColumnPadStudio/Services/ClipboardTextService.cs
@@ -13,36 +13,19 @@ public static string FormatPastedText(string source, PasteListPreset preset)
return ApplyPastePreset(normalized, preset);
}
- public static int CountLineBreaks(string text)
- {
- var count = 0;
- for (var i = 0; i < text.Length; i++)
- {
- if (text[i] == '\n' || (text[i] == '\r' && (i + 1 >= text.Length || text[i + 1] != '\n')))
- count++;
- }
-
- return count;
- }
-
public static string NormalizeClipboardText(string source)
{
if (string.IsNullOrEmpty(source))
return string.Empty;
- while (source.Contains("\r\r\n", StringComparison.Ordinal))
- source = source.Replace("\r\r\n", "\r\n", StringComparison.Ordinal);
-
source = source
.Replace("\u2028", "\n", StringComparison.Ordinal)
- .Replace("\u2029", "\n", StringComparison.Ordinal)
- .Replace("\n\r", "\n", StringComparison.Ordinal);
+ .Replace("\u2029", "\n", StringComparison.Ordinal);
var normalized = source
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace("\r", "\n", StringComparison.Ordinal);
- normalized = CollapseAlternatingBlankClipboardLines(normalized);
return normalized.Replace("\n", Environment.NewLine, StringComparison.Ordinal);
}
@@ -80,64 +63,4 @@ public static string ApplyPastePreset(string source, PasteListPreset preset)
return string.Join(Environment.NewLine, lines);
}
-
- private static string CollapseAlternatingBlankClipboardLines(string text)
- {
- var lines = text.Split('\n');
- if (lines.Length < 6)
- return text;
-
- for (var i = 0; i < lines.Length - 1; i++)
- {
- if (!string.IsNullOrWhiteSpace(lines[i]) && !string.IsNullOrWhiteSpace(lines[i + 1]))
- return text;
- }
-
- var evenCount = 0;
- var oddCount = 0;
- var evenBlank = 0;
- var oddBlank = 0;
- var evenContent = 0;
- var oddContent = 0;
-
- for (var i = 0; i < lines.Length; i++)
- {
- var isBlank = string.IsNullOrWhiteSpace(lines[i]);
- if ((i & 1) == 0)
- {
- evenCount++;
- if (isBlank)
- evenBlank++;
- else
- evenContent++;
- }
- else
- {
- oddCount++;
- if (isBlank)
- oddBlank++;
- else
- oddContent++;
- }
- }
-
- var collapseOdd = oddCount > 0 &&
- oddBlank >= (int)Math.Ceiling(oddCount * 0.85) &&
- evenContent >= 3 &&
- evenBlank <= 1;
- var collapseEven = evenCount > 0 &&
- evenBlank >= (int)Math.Ceiling(evenCount * 0.85) &&
- oddContent >= 3 &&
- oddBlank <= 1;
-
- if (!collapseOdd && !collapseEven)
- return text;
-
- var blankParityToRemove = collapseOdd ? 1 : 0;
- var filtered = lines
- .Where((line, index) => !((index & 1) == blankParityToRemove && string.IsNullOrWhiteSpace(line)))
- .ToArray();
-
- return string.Join('\n', filtered);
- }
}
diff --git a/src/ColumnPadStudio/Services/ColumnImageFileService.cs b/src/ColumnPadStudio/Services/ColumnImageFileService.cs
index 409217f..d0d9539 100644
--- a/src/ColumnPadStudio/Services/ColumnImageFileService.cs
+++ b/src/ColumnPadStudio/Services/ColumnImageFileService.cs
@@ -1,4 +1,6 @@
using System.IO;
+using System.Security.Cryptography;
+using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace ColumnPadStudio.Services;
@@ -8,10 +10,22 @@ public sealed record ColumnImageImport(
string OriginalFileName,
double DisplayWidth,
int PixelWidth,
+ int PixelHeight,
+ string AssetId,
+ byte[] Content);
+
+public sealed record ColumnImageDisplay(
+ ImageSource Source,
+ int PixelWidth,
int PixelHeight);
public static class ColumnImageFileService
{
+ public const int MaxImageFileBytes = 25 * 1024 * 1024;
+ public const long MaxImagePixelCount = 80_000_000;
+ public const int MaxImageDimension = 20_000;
+ private const int MaxDisplayDecodeDimension = 2000;
+
private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png",
@@ -39,71 +53,114 @@ public static ColumnImageImport ImportImage(string sourcePath)
if (!SupportedExtensions.Contains(extension))
throw new NotSupportedException("ColumnPad supports PNG, JPG, BMP, GIF, WEBP, and TIFF images.");
- var (pixelWidth, pixelHeight) = ReadPixelSize(sourcePath);
+ var fileLength = new FileInfo(sourcePath).Length;
+ if (fileLength <= 0 || fileLength > MaxImageFileBytes)
+ throw new InvalidDataException($"Pictures must be between 1 byte and {MaxImageFileBytes / (1024 * 1024)} MB.");
- Directory.CreateDirectory(AppStoragePaths.ImagesDirectory);
+ var content = File.ReadAllBytes(sourcePath);
+ var (pixelWidth, pixelHeight) = ReadPixelSize(content);
+ ValidateDimensions(pixelWidth, pixelHeight);
var originalFileName = Path.GetFileName(sourcePath);
- var safeBaseName = SanitizeFileName(Path.GetFileNameWithoutExtension(sourcePath));
- var storedFileName = $"{safeBaseName}-{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
- var storedPath = Path.Combine(AppStoragePaths.ImagesDirectory, storedFileName);
+ var assetId = ComputeAssetId(content);
+ var displayWidth = Math.Clamp(pixelWidth > 0 ? pixelWidth : 320.0, 160.0, 900.0);
+
+ return new ColumnImageImport(string.Empty, originalFileName, displayWidth, pixelWidth, pixelHeight, assetId, content);
+ }
+
+ public static ColumnImageDisplay? LoadDisplaySource(byte[]? content, string? fallbackPath)
+ {
+ var resolvedContent = content is { Length: > 0 and <= MaxImageFileBytes }
+ ? content
+ : TryReadImageContent(fallbackPath);
+ if (resolvedContent is null)
+ return null;
try
{
- File.Copy(sourcePath, storedPath, overwrite: false);
+ var (pixelWidth, pixelHeight) = ReadPixelSize(resolvedContent);
+ ValidateDimensions(pixelWidth, pixelHeight);
+
+ using var stream = new MemoryStream(resolvedContent, writable: false);
+ var image = new BitmapImage();
+ image.BeginInit();
+ image.CacheOption = BitmapCacheOption.OnLoad;
+ image.CreateOptions = BitmapCreateOptions.PreservePixelFormat;
+ if (pixelWidth >= pixelHeight && pixelWidth > MaxDisplayDecodeDimension)
+ image.DecodePixelWidth = MaxDisplayDecodeDimension;
+ else if (pixelHeight > MaxDisplayDecodeDimension)
+ image.DecodePixelHeight = MaxDisplayDecodeDimension;
+ image.StreamSource = stream;
+ image.EndInit();
+ image.Freeze();
+ return new ColumnImageDisplay(image, pixelWidth, pixelHeight);
}
- catch
+ catch (Exception ex) when (ex is IOException
+ or UnauthorizedAccessException
+ or NotSupportedException
+ or FormatException
+ or InvalidOperationException
+ or InvalidDataException)
{
- TryDeleteIncompleteCopy(storedPath);
- throw;
+ return null;
}
+ }
- var displayWidth = Math.Clamp(pixelWidth > 0 ? pixelWidth : 320.0, 160.0, 900.0);
+ public static byte[]? TryReadImageContent(string? filePath)
+ {
+ if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
+ return null;
- return new ColumnImageImport(storedPath, originalFileName, displayWidth, pixelWidth, pixelHeight);
+ try
+ {
+ var fileLength = new FileInfo(filePath).Length;
+ return fileLength is > 0 and <= MaxImageFileBytes
+ ? File.ReadAllBytes(filePath)
+ : null;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
}
- private static (int Width, int Height) ReadPixelSize(string imagePath)
+ private static (int Width, int Height) ReadPixelSize(byte[] content)
{
try
{
- using var stream = File.OpenRead(imagePath);
+ using var stream = new MemoryStream(content, writable: false);
var decoder = BitmapDecoder.Create(
stream,
- BitmapCreateOptions.PreservePixelFormat,
- BitmapCacheOption.OnLoad);
+ BitmapCreateOptions.PreservePixelFormat | BitmapCreateOptions.DelayCreation,
+ BitmapCacheOption.None);
var frame = decoder.Frames.FirstOrDefault();
- return frame is null
- ? (0, 0)
- : (frame.PixelWidth, frame.PixelHeight);
+ return frame is null ? (0, 0) : (frame.PixelWidth, frame.PixelHeight);
}
- catch (Exception ex) when (ex is IOException or NotSupportedException)
+ catch (Exception ex) when (ex is IOException
+ or NotSupportedException
+ or FormatException
+ or InvalidOperationException)
{
throw new InvalidDataException("The selected file could not be read as an image.", ex);
}
}
- private static string SanitizeFileName(string? value)
+ private static void ValidateDimensions(int pixelWidth, int pixelHeight)
{
- var name = string.IsNullOrWhiteSpace(value) ? "image" : value.Trim();
- var invalidChars = Path.GetInvalidFileNameChars();
- var sanitized = new string(name.Select(ch => invalidChars.Contains(ch) ? '-' : ch).ToArray()).Trim('-', ' ');
- return string.IsNullOrWhiteSpace(sanitized) ? "image" : sanitized;
+ if (pixelWidth <= 0 || pixelHeight <= 0 ||
+ pixelWidth > MaxImageDimension ||
+ pixelHeight > MaxImageDimension ||
+ (long)pixelWidth * pixelHeight > MaxImagePixelCount)
+ {
+ throw new InvalidDataException(
+ $"Picture dimensions must be no larger than {MaxImageDimension:N0} pixels per side or {MaxImagePixelCount:N0} pixels in total.");
+ }
}
- private static void TryDeleteIncompleteCopy(string filePath)
+ private static string ComputeAssetId(byte[] content)
{
- try
- {
- if (File.Exists(filePath))
- File.Delete(filePath);
- }
- catch (IOException)
- {
- }
- catch (UnauthorizedAccessException)
- {
- }
+ return Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
}
+
}
diff --git a/src/ColumnPadStudio/Services/ColumnTextColorService.cs b/src/ColumnPadStudio/Services/ColumnTextColorService.cs
new file mode 100644
index 0000000..3d43422
--- /dev/null
+++ b/src/ColumnPadStudio/Services/ColumnTextColorService.cs
@@ -0,0 +1,83 @@
+using System.Globalization;
+using System.Windows.Media;
+
+namespace ColumnPadStudio.Services;
+
+public static class ColumnTextColorService
+{
+ public const string ThemeDefault = "Theme";
+ public const string Red = "Red";
+ public const string Orange = "Orange";
+ public const string Green = "Green";
+ public const string Teal = "Teal";
+ public const string Blue = "Blue";
+ public const string Purple = "Purple";
+ public const string Grey = "Grey";
+
+ public static IReadOnlyList Presets { get; } =
+ [
+ ThemeDefault,
+ Red,
+ Orange,
+ Green,
+ Teal,
+ Blue,
+ Purple,
+ Grey
+ ];
+
+ public static string Normalize(string? value)
+ {
+ var candidate = value?.Trim();
+ foreach (var preset in Presets)
+ {
+ if (string.Equals(candidate, preset, StringComparison.OrdinalIgnoreCase))
+ return preset;
+ }
+
+ return TryNormalizeCustomHex(candidate, out var customHex)
+ ? customHex
+ : ThemeDefault;
+ }
+
+ public static bool IsCustom(string? value)
+ {
+ return TryNormalizeCustomHex(value, out _);
+ }
+
+ public static bool TryNormalizeCustomHex(string? value, out string normalized)
+ {
+ normalized = string.Empty;
+ var candidate = value?.Trim();
+ if (string.IsNullOrWhiteSpace(candidate))
+ return false;
+
+ if (candidate.StartsWith('#'))
+ candidate = candidate[1..];
+
+ if (candidate.Length != 6
+ || !int.TryParse(candidate, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _))
+ {
+ return false;
+ }
+
+ normalized = $"#{candidate.ToUpperInvariant()}";
+ return true;
+ }
+
+ public static SolidColorBrush? CreateCustomBrush(string? value)
+ {
+ if (!TryNormalizeCustomHex(value, out var normalized))
+ return null;
+
+ var rgb = int.Parse(normalized.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+ var brush = new SolidColorBrush(Color.FromRgb(
+ (byte)((rgb >> 16) & 0xFF),
+ (byte)((rgb >> 8) & 0xFF),
+ (byte)(rgb & 0xFF)));
+ if (brush.CanFreeze)
+ brush.Freeze();
+
+ return brush;
+ }
+}
diff --git a/src/ColumnPadStudio/Services/FileWorkflowService.cs b/src/ColumnPadStudio/Services/FileWorkflowService.cs
index a54cc2b..499ac02 100644
--- a/src/ColumnPadStudio/Services/FileWorkflowService.cs
+++ b/src/ColumnPadStudio/Services/FileWorkflowService.cs
@@ -9,32 +9,32 @@ public sealed record FileDialogDefinition(string FileName, string Filter, string
public enum OpenFileLoadKind
{
TextDocument,
- MarkdownDocument,
TextExport,
- MarkdownExport,
+ JsonExport,
WorkspaceSession,
WorkflowJson,
- LayoutJson
+ LayoutJson,
+ Unsupported
}
public static class FileWorkflowService
{
- public const string SupportedOpenFileFilter = "Supported Files (*.columnpad.json;*.txt;*.md;*.json)|*.columnpad.json;*.txt;*.md;*.json|Layout Files (*.columnpad.json;*.json)|*.columnpad.json;*.json|Text Documents (*.txt)|*.txt|Markdown Documents (*.md)|*.md|All files (*.*)|*.*";
+ public const string SupportedOpenFileFilter = "Supported Files (*.columnpad.json;*.json;*.txt)|*.columnpad.json;*.json;*.txt|ColumnPad and JSON Files (*.columnpad.json;*.json)|*.columnpad.json;*.json|Text Documents (*.txt)|*.txt|All files (*.*)|*.*";
public static OpenFileLoadKind ClassifyOpenFile(string? extension, string? content)
{
var normalizedExtension = (extension ?? string.Empty).ToLowerInvariant();
- return normalizedExtension switch
+ if (string.Equals(normalizedExtension, ".txt", StringComparison.Ordinal))
{
- ".txt" => WorkspaceImportRules.LooksLikeTextExport(content)
+ return WorkspaceImportRules.LooksLikeTextExport(content)
? OpenFileLoadKind.TextExport
- : OpenFileLoadKind.TextDocument,
- ".md" => WorkspaceImportRules.LooksLikeMarkdownExport(content)
- ? OpenFileLoadKind.MarkdownExport
- : OpenFileLoadKind.MarkdownDocument,
- _ => ClassifyJsonFile(content)
- };
+ : OpenFileLoadKind.TextDocument;
+ }
+
+ return normalizedExtension.EndsWith(".json", StringComparison.Ordinal)
+ ? ClassifyJsonFile(content)
+ : OpenFileLoadKind.Unsupported;
}
private static OpenFileLoadKind ClassifyJsonFile(string? content)
@@ -42,6 +42,9 @@ private static OpenFileLoadKind ClassifyJsonFile(string? content)
if (WorkspaceSessionFileService.IsWorkspaceSessionJson(content))
return OpenFileLoadKind.WorkspaceSession;
+ if (WorkspaceImportRules.IsJsonExport(content))
+ return OpenFileLoadKind.JsonExport;
+
return WorkflowService.IsWorkflowDefinitionJson(content)
? OpenFileLoadKind.WorkflowJson
: OpenFileLoadKind.LayoutJson;
@@ -73,22 +76,16 @@ public static FileDialogDefinition BuildSaveDialog(
DefaultExt: ".txt",
AddExtension: true),
- SaveFileKind.MarkdownDocument => new FileDialogDefinition(
- FileName: BuildSuggestedSaveFileName(currentFilePath, requiresSaveAsBeforeOverwrite, "document.md"),
- Filter: "Markdown (*.md)|*.md|All files (*.*)|*.*",
- DefaultExt: ".md",
- AddExtension: true),
-
SaveFileKind.TextExport => new FileDialogDefinition(
FileName: BuildSuggestedSaveFileName(currentFilePath, requiresSaveAsBeforeOverwrite, "ColumnPad_export.txt"),
Filter: "Text (*.txt)|*.txt|All files (*.*)|*.*",
DefaultExt: ".txt",
AddExtension: true),
- SaveFileKind.MarkdownExport => new FileDialogDefinition(
- FileName: BuildSuggestedSaveFileName(currentFilePath, requiresSaveAsBeforeOverwrite, "ColumnPad_export.md"),
- Filter: "Markdown (*.md)|*.md|All files (*.*)|*.*",
- DefaultExt: ".md",
+ SaveFileKind.JsonExport => new FileDialogDefinition(
+ FileName: BuildSuggestedSaveFileName(currentFilePath, requiresSaveAsBeforeOverwrite, "ColumnPad_export.json"),
+ Filter: "ColumnPad Text Export (*.json)|*.json|All files (*.*)|*.*",
+ DefaultExt: ".json",
AddExtension: true),
_ => new FileDialogDefinition(
diff --git a/src/ColumnPadStudio/Services/LatestWriteCoordinator.cs b/src/ColumnPadStudio/Services/LatestWriteCoordinator.cs
new file mode 100644
index 0000000..9dd82a6
--- /dev/null
+++ b/src/ColumnPadStudio/Services/LatestWriteCoordinator.cs
@@ -0,0 +1,190 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace ColumnPadStudio.Services;
+
+///
+/// Runs one background write at a time and keeps only the newest queued value.
+///
+internal sealed class LatestWriteCoordinator : IDisposable
+{
+ private readonly object _gate = new();
+ private readonly Func _writeAsync;
+ private readonly Action _writeCompleted;
+
+ private CancellationTokenSource _writeCancellation = new();
+ private Task _workerTask = Task.CompletedTask;
+ private T? _pendingValue;
+ private bool _hasPendingValue;
+ private bool _workerActive;
+ private bool _acceptingWrites = true;
+
+ public LatestWriteCoordinator(
+ Func writeAsync,
+ Action writeCompleted)
+ {
+ _writeAsync = writeAsync ?? throw new ArgumentNullException(nameof(writeAsync));
+ _writeCompleted = writeCompleted ?? throw new ArgumentNullException(nameof(writeCompleted));
+ }
+
+ public void Queue(T value)
+ {
+ lock (_gate)
+ {
+ if (!_acceptingWrites)
+ return;
+
+ _pendingValue = value;
+ _hasPendingValue = true;
+ if (!_workerActive)
+ StartWorkerLocked();
+ }
+ }
+
+ public async Task PauseAsync()
+ {
+ Task workerTask;
+ CancellationTokenSource cancellation;
+ lock (_gate)
+ {
+ _acceptingWrites = false;
+ _pendingValue = default;
+ _hasPendingValue = false;
+ workerTask = _workerTask;
+ cancellation = _writeCancellation;
+ }
+
+ cancellation.Cancel();
+ await workerTask.ConfigureAwait(false);
+ }
+
+ public void Resume()
+ {
+ CancellationTokenSource previousCancellation;
+ lock (_gate)
+ {
+ if (_workerActive)
+ throw new InvalidOperationException("The recovery writer must finish pausing before it can resume.");
+
+ previousCancellation = _writeCancellation;
+ _writeCancellation = new CancellationTokenSource();
+ _acceptingWrites = true;
+ }
+
+ previousCancellation.Dispose();
+ }
+
+ public void StopAcceptingWithoutCancellation()
+ {
+ lock (_gate)
+ {
+ _acceptingWrites = false;
+ _pendingValue = default;
+ _hasPendingValue = false;
+ }
+ }
+
+ public async Task WaitForIdleAsync()
+ {
+ while (true)
+ {
+ Task workerTask;
+ lock (_gate)
+ {
+ if (!_workerActive && !_hasPendingValue)
+ return;
+
+ workerTask = _workerTask;
+ }
+
+ await workerTask.ConfigureAwait(false);
+ }
+ }
+
+ public void Dispose()
+ {
+ CancellationTokenSource cancellation;
+ lock (_gate)
+ {
+ if (_workerActive)
+ throw new InvalidOperationException("The recovery writer must be idle before it can be disposed.");
+
+ _acceptingWrites = false;
+ _pendingValue = default;
+ _hasPendingValue = false;
+ cancellation = _writeCancellation;
+ }
+
+ cancellation.Dispose();
+ }
+
+ private void StartWorkerLocked()
+ {
+ _workerActive = true;
+ var cancellationToken = _writeCancellation.Token;
+ _workerTask = Task.Run(() => RunWorkerAsync(cancellationToken));
+ }
+
+ [SuppressMessage(
+ "Design",
+ "CA1031:Do not catch general exception types",
+ Justification = "This is the observed boundary for a fire-and-forget background writer; failures are reported to the UI.")]
+ private async Task RunWorkerAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ while (true)
+ {
+ T value;
+ lock (_gate)
+ {
+ if (cancellationToken.IsCancellationRequested || !_hasPendingValue)
+ return;
+
+ value = _pendingValue!;
+ _pendingValue = default;
+ _hasPendingValue = false;
+ }
+
+ try
+ {
+ await _writeAsync(value, cancellationToken).ConfigureAwait(false);
+ if (!cancellationToken.IsCancellationRequested)
+ NotifyWriteCompleted(null);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ NotifyWriteCompleted(ex);
+ }
+ }
+ }
+ finally
+ {
+ lock (_gate)
+ {
+ _workerActive = false;
+ if (_acceptingWrites && _hasPendingValue && !cancellationToken.IsCancellationRequested)
+ StartWorkerLocked();
+ }
+ }
+ }
+
+ [SuppressMessage(
+ "Design",
+ "CA1031:Do not catch general exception types",
+ Justification = "A result callback must not fault the observed background-writer boundary.")]
+ private void NotifyWriteCompleted(Exception? error)
+ {
+ try
+ {
+ _writeCompleted(error);
+ }
+ catch (Exception)
+ {
+ // The writer remains usable even if its optional status reporter is shutting down.
+ }
+ }
+}
diff --git a/src/ColumnPadStudio/Services/TextSearchService.cs b/src/ColumnPadStudio/Services/TextSearchService.cs
index bec492a..5fc332e 100644
--- a/src/ColumnPadStudio/Services/TextSearchService.cs
+++ b/src/ColumnPadStudio/Services/TextSearchService.cs
@@ -21,8 +21,7 @@ public static bool TryFindNext(
{
result = default;
- if (columnTexts is null)
- throw new ArgumentNullException(nameof(columnTexts));
+ ArgumentNullException.ThrowIfNull(columnTexts);
if (columnTexts.Count == 0 || string.IsNullOrWhiteSpace(findText))
return false;
diff --git a/src/ColumnPadStudio/Services/ThemeResourceService.cs b/src/ColumnPadStudio/Services/ThemeResourceService.cs
index 21dc338..8cdd082 100644
--- a/src/ColumnPadStudio/Services/ThemeResourceService.cs
+++ b/src/ColumnPadStudio/Services/ThemeResourceService.cs
@@ -1,252 +1,29 @@
+using System.Collections;
using System.Windows;
-using System.Windows.Media;
namespace ColumnPadStudio.Services;
public static class ThemeResourceService
{
+ private const string ThemeResourceRoot = "pack://application:,,,/ColumnPadStudio;component/Resources/Themes/";
+
public static void ApplyTheme(ResourceDictionary resources, string preset)
{
ArgumentNullException.ThrowIfNull(resources);
- if (string.Equals(preset, ThemePresetService.DarkPreset, StringComparison.Ordinal))
- {
- SetBrush(resources, "WindowBackgroundBrush", "#FF202020");
- SetBrush(resources, "MenuBackgroundBrush", "#FF2A2A2A");
- SetBrush(resources, "ToolbarBackgroundBrush", "#FF2A2F37");
- SetBrush(resources, "ControlForegroundBrush", "#FFF2F2F2");
- SetBrush(resources, "ControlBackgroundBrush", "#FF3A3A3A");
- SetBrush(resources, "ControlBorderBrush", "#FF6A6A6A");
- SetBrush(resources, "ControlHoverBackgroundBrush", "#FF454C56");
- SetBrush(resources, "ControlPressedBackgroundBrush", "#FF3A4452");
- SetBrush(resources, "ControlFocusBorderBrush", "#FF8F949A");
- SetDarkWorkflowNodeBrushes(resources);
- SetBrush(resources, "ControlPopupBackgroundBrush", "#FF2F2F2F");
- SetBrush(resources, "ControlPopupForegroundBrush", "#FFF2F2F2");
- SetBrush(resources, "ControlPopupHighlightBrush", "#FF454C56");
- SetBrush(resources, "ControlPopupHighlightTextBrush", "#FFFFFFFF");
- SetBrush(resources, "ColumnHostBackgroundBrush", "#FF232323");
- SetBrush(resources, "ColumnHeaderBackgroundBrush", "#FF2D2D2D");
- SetBrush(resources, "ColumnSelectedHeaderBackgroundBrush", "#FF34404D");
- SetBrush(resources, "EditorBackgroundBrush", "#FF171717");
- SetBrush(resources, "EditorForegroundBrush", "#FFF2F2F2");
- SetBrush(resources, "EditorSelectionBrush", "#FF555B63");
- SetBrush(resources, "EditorSelectionTextBrush", "#FFFFFFFF");
- SetBrush(resources, "EditorInactiveSelectionBrush", "#FF3F4348");
- SetBrush(resources, "EditorInactiveSelectionTextBrush", "#FFFFFFFF");
- SetBrush(resources, "LinedPaperLineBrush", "#FF3D5E8A");
- SetLinedPaperBrush(resources, "#FF171717", "#FF3D5E8A");
- SetBrush(resources, "LineNumberBackgroundBrush", "#FF222222");
- SetBrush(resources, "LineNumberForegroundBrush", "#FFB8B8B8");
- SetBrush(resources, "StatusBackgroundBrush", "#FF2A2A2A");
- SetBrush(resources, SystemColors.HighlightBrushKey, "#FF454C56");
- SetBrush(resources, SystemColors.HighlightTextBrushKey, "#FFFFFFFF");
- SetBrush(resources, SystemColors.MenuHighlightBrushKey, "#FF454C56");
- SetBrush(resources, SystemColors.HotTrackBrushKey, "#FF8F949A");
- SetBrush(resources, SystemColors.InactiveSelectionHighlightBrushKey, "#FF3F4348");
- SetBrush(resources, SystemColors.InactiveSelectionHighlightTextBrushKey, "#FFFFFFFF");
- SetBrush(resources, SystemColors.MenuBrushKey, "#FF2F2F2F");
- SetBrush(resources, SystemColors.MenuTextBrushKey, "#FFF2F2F2");
- SetBrush(resources, SystemColors.GrayTextBrushKey, "#FF9EA7B3");
- SetBrush(resources, SystemColors.ControlTextBrushKey, "#FFF2F2F2");
- SetBrush(resources, SystemColors.WindowBrushKey, "#FF2F2F2F");
- SetBrush(resources, SystemColors.WindowTextBrushKey, "#FFF2F2F2");
- SetBrush(resources, SystemColors.ControlBrushKey, "#FF3A3A3A");
- SetBrush(resources, SystemColors.InfoBrushKey, "#FF2F2F2F");
- SetBrush(resources, SystemColors.InfoTextBrushKey, "#FFF2F2F2");
- return;
- }
-
- if (string.Equals(preset, ThemePresetService.DefaultPreset, StringComparison.Ordinal))
+ var themeFile = ThemePresetService.Normalize(preset) switch
{
- SetBrush(resources, "WindowBackgroundBrush", "#FFEDEAE1");
- SetBrush(resources, "MenuBackgroundBrush", "#FFF2EFE6");
- SetBrush(resources, "ToolbarBackgroundBrush", "#FFE6E0D3");
- SetBrush(resources, "ControlForegroundBrush", "#FF1C1C1C");
- SetBrush(resources, "ControlBackgroundBrush", "#FFF8F3E8");
- SetBrush(resources, "ControlBorderBrush", "#FFC8BFAE");
- SetBrush(resources, "ControlHoverBackgroundBrush", "#FFFFFAEE");
- SetBrush(resources, "ControlPressedBackgroundBrush", "#FFE9DFC9");
- SetBrush(resources, "ControlFocusBorderBrush", "#FF8C8171");
- SetDefaultWorkflowNodeBrushes(resources);
- SetBrush(resources, "ControlPopupBackgroundBrush", "#FFF8F3E8");
- SetBrush(resources, "ControlPopupForegroundBrush", "#FF1C1C1C");
- SetBrush(resources, "ControlPopupHighlightBrush", "#FFE9DFC9");
- SetBrush(resources, "ControlPopupHighlightTextBrush", "#FF1C1C1C");
- SetBrush(resources, "ColumnHostBackgroundBrush", "#FFF1EDE4");
- SetBrush(resources, "ColumnHeaderBackgroundBrush", "#FFD9D1C0");
- SetBrush(resources, "ColumnSelectedHeaderBackgroundBrush", "#FFE8DEC8");
- SetBrush(resources, "EditorBackgroundBrush", "#FFFFFCF4");
- SetBrush(resources, "EditorForegroundBrush", "#FF1C1C1C");
- SetBrush(resources, "EditorSelectionBrush", "#FFD8CDB8");
- SetBrush(resources, "EditorSelectionTextBrush", "#FF1C1C1C");
- SetBrush(resources, "EditorInactiveSelectionBrush", "#FFE8E0D2");
- SetBrush(resources, "EditorInactiveSelectionTextBrush", "#FF1C1C1C");
- SetBrush(resources, "LinedPaperLineBrush", "#FF9DBFE8");
- SetLinedPaperBrush(resources, "#FFFFFCF4", "#FF9DBFE8");
- SetBrush(resources, "LineNumberBackgroundBrush", "#FFEEE7D8");
- SetBrush(resources, "LineNumberForegroundBrush", "#FF7B7469");
- SetBrush(resources, "StatusBackgroundBrush", "#FFE8E2D5");
- SetBrush(resources, SystemColors.HighlightBrushKey, "#FFE9DFC9");
- SetBrush(resources, SystemColors.HighlightTextBrushKey, "#FF1C1C1C");
- SetBrush(resources, SystemColors.MenuHighlightBrushKey, "#FFE9DFC9");
- SetBrush(resources, SystemColors.HotTrackBrushKey, "#FF8C8171");
- SetBrush(resources, SystemColors.InactiveSelectionHighlightBrushKey, "#FFE8E0D2");
- SetBrush(resources, SystemColors.InactiveSelectionHighlightTextBrushKey, "#FF1C1C1C");
- SetBrush(resources, SystemColors.MenuBrushKey, "#FFF8F3E8");
- SetBrush(resources, SystemColors.MenuTextBrushKey, "#FF1C1C1C");
- SetBrush(resources, SystemColors.GrayTextBrushKey, "#FF7B7469");
- SetBrush(resources, SystemColors.ControlTextBrushKey, "#FF1C1C1C");
- SetBrush(resources, SystemColors.WindowBrushKey, "#FFF8F3E8");
- SetBrush(resources, SystemColors.WindowTextBrushKey, "#FF1C1C1C");
- SetBrush(resources, SystemColors.ControlBrushKey, "#FFF8F3E8");
- SetBrush(resources, SystemColors.InfoBrushKey, "#FFF8F3E8");
- SetBrush(resources, SystemColors.InfoTextBrushKey, "#FF1C1C1C");
- return;
- }
-
- SetBrush(resources, "WindowBackgroundBrush", "#FFEFEFEF");
- SetBrush(resources, "MenuBackgroundBrush", "#FFF5F5F5");
- SetBrush(resources, "ToolbarBackgroundBrush", "#FFE8EEF6");
- SetBrush(resources, "ControlForegroundBrush", "#FF111111");
- SetBrush(resources, "ControlBackgroundBrush", "#FFF4F4F4");
- SetBrush(resources, "ControlBorderBrush", "#FFB8B8B8");
- SetBrush(resources, "ControlHoverBackgroundBrush", "#FFFFFFFF");
- SetBrush(resources, "ControlPressedBackgroundBrush", "#FFDCE7F7");
- SetBrush(resources, "ControlFocusBorderBrush", "#FF8A8A8A");
- SetLightWorkflowNodeBrushes(resources);
- SetBrush(resources, "ControlPopupBackgroundBrush", "#FFF4F4F4");
- SetBrush(resources, "ControlPopupForegroundBrush", "#FF111111");
- SetBrush(resources, "ControlPopupHighlightBrush", "#FFE8EEF6");
- SetBrush(resources, "ControlPopupHighlightTextBrush", "#FF111111");
- SetBrush(resources, "ColumnHostBackgroundBrush", "#FFF2F2F2");
- SetBrush(resources, "ColumnHeaderBackgroundBrush", "#FFE4E4E4");
- SetBrush(resources, "ColumnSelectedHeaderBackgroundBrush", "#FFE8EEF6");
- SetBrush(resources, "EditorBackgroundBrush", "#FFFFFFFF");
- SetBrush(resources, "EditorForegroundBrush", "#FF111111");
- SetBrush(resources, "EditorSelectionBrush", "#FFD9D9D9");
- SetBrush(resources, "EditorSelectionTextBrush", "#FF111111");
- SetBrush(resources, "EditorInactiveSelectionBrush", "#FFE8E8E8");
- SetBrush(resources, "EditorInactiveSelectionTextBrush", "#FF111111");
- SetBrush(resources, "LinedPaperLineBrush", "#FFB5CFF2");
- SetLinedPaperBrush(resources, "#FFFFFFFF", "#FFB5CFF2");
- SetBrush(resources, "LineNumberBackgroundBrush", "#FFF7F7F7");
- SetBrush(resources, "LineNumberForegroundBrush", "#FF7A7A7A");
- SetBrush(resources, "StatusBackgroundBrush", "#FFF3F3F3");
- SetBrush(resources, SystemColors.HighlightBrushKey, "#FFE8EEF6");
- SetBrush(resources, SystemColors.HighlightTextBrushKey, "#FF111111");
- SetBrush(resources, SystemColors.MenuHighlightBrushKey, "#FFE8EEF6");
- SetBrush(resources, SystemColors.HotTrackBrushKey, "#FF8A8A8A");
- SetBrush(resources, SystemColors.InactiveSelectionHighlightBrushKey, "#FFE8E8E8");
- SetBrush(resources, SystemColors.InactiveSelectionHighlightTextBrushKey, "#FF111111");
- SetBrush(resources, SystemColors.MenuBrushKey, "#FFF4F4F4");
- SetBrush(resources, SystemColors.MenuTextBrushKey, "#FF111111");
- SetBrush(resources, SystemColors.GrayTextBrushKey, "#FF7A7A7A");
- SetBrush(resources, SystemColors.ControlTextBrushKey, "#FF111111");
- SetBrush(resources, SystemColors.WindowBrushKey, "#FFF4F4F4");
- SetBrush(resources, SystemColors.WindowTextBrushKey, "#FF111111");
- SetBrush(resources, SystemColors.ControlBrushKey, "#FFF4F4F4");
- SetBrush(resources, SystemColors.InfoBrushKey, "#FFF4F4F4");
- SetBrush(resources, SystemColors.InfoTextBrushKey, "#FF111111");
- }
-
- private static void SetDarkWorkflowNodeBrushes(ResourceDictionary resources)
- {
- SetBrush(resources, "WorkflowNodeAutoBackgroundBrush", "#FF243850");
- SetBrush(resources, "WorkflowNodeAutoBorderBrush", "#FF7EA4CA");
- SetBrush(resources, "WorkflowNodeBlueBackgroundBrush", "#FF1F3C5B");
- SetBrush(resources, "WorkflowNodeBlueBorderBrush", "#FF72A7DC");
- SetBrush(resources, "WorkflowNodeGreenBackgroundBrush", "#FF213F2E");
- SetBrush(resources, "WorkflowNodeGreenBorderBrush", "#FF7AB385");
- SetBrush(resources, "WorkflowNodeAmberBackgroundBrush", "#FF4B3820");
- SetBrush(resources, "WorkflowNodeAmberBorderBrush", "#FFD29A52");
- SetBrush(resources, "WorkflowNodeRoseBackgroundBrush", "#FF4B2830");
- SetBrush(resources, "WorkflowNodeRoseBorderBrush", "#FFD3838F");
- SetBrush(resources, "WorkflowNodeSlateBackgroundBrush", "#FF303742");
- SetBrush(resources, "WorkflowNodeSlateBorderBrush", "#FF93A0AF");
- }
-
- private static void SetDefaultWorkflowNodeBrushes(ResourceDictionary resources)
- {
- SetBrush(resources, "WorkflowNodeAutoBackgroundBrush", "#FFF2E8D8");
- SetBrush(resources, "WorkflowNodeAutoBorderBrush", "#FFC59A69");
- SetBrush(resources, "WorkflowNodeBlueBackgroundBrush", "#FFE2ECF7");
- SetBrush(resources, "WorkflowNodeBlueBorderBrush", "#FF5D7FA8");
- SetBrush(resources, "WorkflowNodeGreenBackgroundBrush", "#FFE7F2DD");
- SetBrush(resources, "WorkflowNodeGreenBorderBrush", "#FF678E5B");
- SetBrush(resources, "WorkflowNodeAmberBackgroundBrush", "#FFFFF0D4");
- SetBrush(resources, "WorkflowNodeAmberBorderBrush", "#FFB78331");
- SetBrush(resources, "WorkflowNodeRoseBackgroundBrush", "#FFF8E2DE");
- SetBrush(resources, "WorkflowNodeRoseBorderBrush", "#FFB76A60");
- SetBrush(resources, "WorkflowNodeSlateBackgroundBrush", "#FFE9E1D4");
- SetBrush(resources, "WorkflowNodeSlateBorderBrush", "#FF877D71");
- }
-
- private static void SetLightWorkflowNodeBrushes(ResourceDictionary resources)
- {
- SetBrush(resources, "WorkflowNodeAutoBackgroundBrush", "#FFEAF3FF");
- SetBrush(resources, "WorkflowNodeAutoBorderBrush", "#FF7D8FA3");
- SetBrush(resources, "WorkflowNodeBlueBackgroundBrush", "#FFE7F0FF");
- SetBrush(resources, "WorkflowNodeBlueBorderBrush", "#FF5B7EAD");
- SetBrush(resources, "WorkflowNodeGreenBackgroundBrush", "#FFE7F8E7");
- SetBrush(resources, "WorkflowNodeGreenBorderBrush", "#FF4F8A4F");
- SetBrush(resources, "WorkflowNodeAmberBackgroundBrush", "#FFFFF6DB");
- SetBrush(resources, "WorkflowNodeAmberBorderBrush", "#FFB28A22");
- SetBrush(resources, "WorkflowNodeRoseBackgroundBrush", "#FFFDEAEA");
- SetBrush(resources, "WorkflowNodeRoseBorderBrush", "#FFB15A5A");
- SetBrush(resources, "WorkflowNodeSlateBackgroundBrush", "#FFF1F1F1");
- SetBrush(resources, "WorkflowNodeSlateBorderBrush", "#FF8A8A8A");
- }
- private static void SetBrush(ResourceDictionary resources, string key, string hex)
- => SetBrush(resources, (object)key, hex);
-
- private static void SetBrush(ResourceDictionary resources, object key, string hex)
- {
- var brush = (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
- if (brush.CanFreeze)
- brush.Freeze();
-
- resources[key] = brush;
- }
-
- private static void SetLinedPaperBrush(ResourceDictionary resources, string backgroundHex, string lineHex)
- {
- var backgroundBrush = (SolidColorBrush)new BrushConverter().ConvertFromString(backgroundHex)!;
- var lineBrush = (SolidColorBrush)new BrushConverter().ConvertFromString(lineHex)!;
-
- if (backgroundBrush.CanFreeze)
- backgroundBrush.Freeze();
-
- if (lineBrush.CanFreeze)
- lineBrush.Freeze();
+ ThemePresetService.DarkPreset => "DarkTheme.xaml",
+ ThemePresetService.DefaultPreset => "DefaultTheme.xaml",
+ _ => "LightTheme.xaml"
+ };
- var drawingBrush = new DrawingBrush
+ var palette = new ResourceDictionary
{
- TileMode = TileMode.Tile,
- Viewport = new Rect(0, 0, 200, 23),
- ViewportUnits = BrushMappingMode.Absolute,
- Viewbox = new Rect(0, 0, 200, 23),
- ViewboxUnits = BrushMappingMode.Absolute,
- Stretch = Stretch.Fill,
- Drawing = new DrawingGroup
- {
- Children =
- {
- new GeometryDrawing(
- backgroundBrush,
- null,
- new RectangleGeometry(new Rect(0, 0, 200, 23))),
- new GeometryDrawing(
- null,
- new Pen(lineBrush, 1),
- new LineGeometry(new Point(0, 22.75), new Point(200, 22.75)))
- }
- }
+ Source = new Uri(ThemeResourceRoot + themeFile, UriKind.Absolute)
};
- if (drawingBrush.CanFreeze)
- drawingBrush.Freeze();
-
- resources["EditorLinedPaperBrush"] = drawingBrush;
+ foreach (DictionaryEntry resource in palette)
+ resources[resource.Key] = resource.Value;
}
}
diff --git a/src/ColumnPadStudio/Services/WorkflowService.Migrations.cs b/src/ColumnPadStudio/Services/WorkflowService.Migrations.cs
new file mode 100644
index 0000000..eed4523
--- /dev/null
+++ b/src/ColumnPadStudio/Services/WorkflowService.Migrations.cs
@@ -0,0 +1,178 @@
+using System.Collections.ObjectModel;
+using System.Text;
+using System.Text.Json;
+using ColumnPadStudio.Workflows;
+
+namespace ColumnPadStudio.Services;
+
+public sealed partial class WorkflowService
+{
+ private static WorkflowDefinition? DeserializeWorkflow(string json)
+ {
+ using var document = JsonDocument.Parse(json);
+ var root = document.RootElement;
+ var hasLegacySteps = TryGetPropertyIgnoreCase(root, "Steps", out var steps) &&
+ steps.ValueKind == JsonValueKind.Array;
+ var hasCurrentNodes = TryGetPropertyIgnoreCase(root, nameof(WorkflowDefinition.Nodes), out var nodes) &&
+ nodes.ValueKind == JsonValueKind.Array;
+
+ return hasLegacySteps && !hasCurrentNodes
+ ? MigrateLegacyStepWorkflow(root, steps)
+ : JsonSerializer.Deserialize(json, JsonOptions);
+ }
+
+ private static WorkflowDefinition MigrateLegacyStepWorkflow(JsonElement root, JsonElement steps)
+ {
+ var workflow = new WorkflowDefinition
+ {
+ SchemaVersion = WorkflowDefinition.CurrentSchemaVersion,
+ Id = ReadLegacyString(root, nameof(WorkflowDefinition.Id), Guid.NewGuid().ToString("N")),
+ Name = ReadLegacyString(root, nameof(WorkflowDefinition.Name), "Imported Workflow"),
+ Category = ReadLegacyString(root, nameof(WorkflowDefinition.Category), "Imported"),
+ Description = ReadLegacyString(root, nameof(WorkflowDefinition.Description), string.Empty),
+ Nodes = [],
+ Links = []
+ };
+
+ workflow.Nodes.Add(new WorkflowDiagramNode
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Kind = WorkflowNodeKind.Start,
+ Title = "Start",
+ Description = workflow.Description,
+ Goal = "Begin the imported workflow.",
+ Instructions = "Review the original workflow steps in order.",
+ ExpectedOutput = "The workflow is ready to begin.",
+ X = 120,
+ Y = 80
+ });
+
+ var stepIndex = 0;
+ foreach (var step in steps.EnumerateArray())
+ {
+ if (step.ValueKind != JsonValueKind.Object)
+ continue;
+
+ stepIndex++;
+ var kindName = ReadLegacyStepKind(step);
+ var title = LegacyStepTitle(kindName, stepIndex);
+ var argument = ReadLegacyString(step, "Argument", string.Empty);
+ var notes = ReadLegacyString(step, "Notes", string.Empty);
+ var instructions = string.IsNullOrWhiteSpace(argument)
+ ? notes
+ : string.IsNullOrWhiteSpace(notes)
+ ? $"Setting or value: {argument}"
+ : $"Setting or value: {argument}{Environment.NewLine}{notes}";
+
+ workflow.Nodes.Add(new WorkflowDiagramNode
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Kind = WorkflowNodeKind.Step,
+ Title = title,
+ Description = notes,
+ Goal = $"Complete the {title.ToLowerInvariant()} action from the original workflow.",
+ Instructions = instructions,
+ ExpectedOutput = $"{title} completed.",
+ X = 120,
+ Y = 80 + (stepIndex * 130)
+ });
+ }
+
+ workflow.Nodes.Add(new WorkflowDiagramNode
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Kind = WorkflowNodeKind.End,
+ Title = "End",
+ Goal = "Finish the imported workflow.",
+ Instructions = "Confirm that each original step has been completed.",
+ ExpectedOutput = "The workflow is complete.",
+ X = 120,
+ Y = 80 + ((stepIndex + 1) * 130)
+ });
+
+ for (var index = 0; index < workflow.Nodes.Count - 1; index++)
+ {
+ workflow.Links.Add(new WorkflowDiagramLink
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ FromNodeId = workflow.Nodes[index].Id,
+ ToNodeId = workflow.Nodes[index + 1].Id
+ });
+ }
+
+ return workflow;
+ }
+
+ private static string ReadLegacyStepKind(JsonElement step)
+ {
+ if (!TryGetPropertyIgnoreCase(step, "Kind", out var kind))
+ return "Step";
+
+ if (kind.ValueKind == JsonValueKind.String)
+ return kind.GetString() ?? "Step";
+
+ if (kind.ValueKind == JsonValueKind.Number && kind.TryGetInt32(out var numericKind))
+ {
+ return numericKind switch
+ {
+ 0 => "AddColumn",
+ 1 => "SetTheme",
+ 2 => "ToggleWordWrap",
+ 3 => "ToggleLineNumbers",
+ 4 => "SaveCurrentFile",
+ 5 => "SetColumnCount",
+ 6 => "SetSpellCheck",
+ 7 => "SetEditorLanguage",
+ 8 => "SetLinedPaper",
+ _ => "Step"
+ };
+ }
+
+ return "Step";
+ }
+
+ private static string LegacyStepTitle(string kindName, int index)
+ {
+ return kindName.ToUpperInvariant() switch
+ {
+ "ADDCOLUMN" => "Add column",
+ "SETTHEME" => "Set theme",
+ "TOGGLEWORDWRAP" => "Set word wrap",
+ "TOGGLELINENUMBERS" => "Set line numbers",
+ "SAVECURRENTFILE" => "Save current file",
+ "SETCOLUMNCOUNT" => "Set column count",
+ "SETSPELLCHECK" => "Set spell check",
+ "SETEDITORLANGUAGE" => "Set proofing language",
+ "SETLINEDPAPER" => "Set paper style",
+ _ => HumanizeLegacyStepKind(kindName, index)
+ };
+ }
+
+ private static string HumanizeLegacyStepKind(string value, int index)
+ {
+ if (string.IsNullOrWhiteSpace(value) || string.Equals(value, "Step", StringComparison.OrdinalIgnoreCase))
+ return $"Step {index}";
+
+ var builder = new StringBuilder(value.Length + 8);
+ for (var characterIndex = 0; characterIndex < value.Length; characterIndex++)
+ {
+ var character = value[characterIndex];
+ if (characterIndex > 0 && char.IsUpper(character) && char.IsLower(value[characterIndex - 1]))
+ builder.Append(' ');
+
+ builder.Append(character);
+ }
+
+ var result = builder.ToString().Trim();
+ return result.Length == 0
+ ? $"Step {index}"
+ : char.ToUpperInvariant(result[0]) + result[1..];
+ }
+
+ private static string ReadLegacyString(JsonElement element, string propertyName, string fallback)
+ {
+ return TryGetPropertyIgnoreCase(element, propertyName, out var value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString() ?? fallback
+ : fallback;
+ }
+}
diff --git a/src/ColumnPadStudio/Services/WorkflowService.ReadableMarkdownExports.cs b/src/ColumnPadStudio/Services/WorkflowService.ReadableMarkdownExports.cs
deleted file mode 100644
index a682451..0000000
--- a/src/ColumnPadStudio/Services/WorkflowService.ReadableMarkdownExports.cs
+++ /dev/null
@@ -1,144 +0,0 @@
-using System.Text;
-using ColumnPadStudio.Workflows;
-
-namespace ColumnPadStudio.Services;
-
-public sealed partial class WorkflowService
-{
- public string BuildMarkdownExport(WorkflowDefinition workflow)
- {
- ArgumentNullException.ThrowIfNull(workflow);
-
- Normalize(workflow, fallbackName: null);
- var export = Snapshot(workflow);
- var orderedNodes = GetReadableNodeOrder(export);
- var nodeIndexes = BuildNodeIndexes(orderedNodes);
-
- var builder = new StringBuilder();
- builder.AppendLine(MarkdownExportMarker);
- builder.AppendLine();
- builder.AppendLine($"# {EscapeMarkdownInline(CleanSingleLine(export.Name, "New Workflow"))}");
- builder.AppendLine();
- builder.AppendLine($"**Category:** {EscapeMarkdownInline(CleanSingleLine(export.Category, "Custom"))}");
- builder.AppendLine();
- builder.AppendLine($"**Trigger:** {export.Trigger}");
- AppendMarkdownBlock(builder, "Description", export.Description);
-
- builder.AppendLine();
- builder.AppendLine("## Steps");
-
- foreach (var node in orderedNodes)
- {
- builder.AppendLine();
- builder.AppendLine($"### {nodeIndexes[node.Id]}. {node.Kind}: {EscapeMarkdownInline(CleanSingleLine(node.Title, WorkflowDiagramNode.DefaultTitleForKind(node.Kind)))}");
- AppendMarkdownBlock(builder, "Description", node.Description);
- AppendMarkdownBlock(builder, "Goal", node.Goal);
- AppendMarkdownBlock(builder, "Instructions", node.Instructions);
- AppendMarkdownBlock(builder, "Expected output", node.ExpectedOutput);
- AppendMarkdownChecklist(builder, node);
- AppendMarkdownNextSteps(builder, export, node, nodeIndexes);
- }
-
- AppendMarkdownConnections(builder, export, nodeIndexes);
- return builder.ToString().TrimEnd() + Environment.NewLine;
- }
-
- private static void AppendMarkdownBlock(StringBuilder builder, string label, string? value)
- {
- var text = CleanMultiline(value);
- if (text.Length == 0)
- return;
-
- builder.AppendLine();
- builder.AppendLine($"**{label}:**");
- builder.AppendLine();
- builder.AppendLine(text);
- }
-
- private static void AppendMarkdownChecklist(StringBuilder builder, WorkflowDiagramNode node)
- {
- var items = node.ChecklistItems
- .Where(item => !string.IsNullOrWhiteSpace(item.Text))
- .ToList();
-
- if (items.Count == 0)
- return;
-
- builder.AppendLine();
- builder.AppendLine("**Checklist:**");
- builder.AppendLine();
- foreach (var item in items)
- {
- var marker = item.IsDone ? "[x]" : "[ ]";
- builder.AppendLine($"- {marker} {item.Text.Trim()}");
- }
- }
-
- private static void AppendMarkdownNextSteps(
- StringBuilder builder,
- WorkflowDefinition workflow,
- WorkflowDiagramNode node,
- IReadOnlyDictionary nodeIndexes)
- {
- var links = GetOutgoingLinks(workflow, node.Id).ToList();
- if (links.Count == 0)
- return;
-
- var nodesById = workflow.Nodes.ToDictionary(item => item.Id, StringComparer.Ordinal);
- builder.AppendLine();
- builder.AppendLine("**Next:**");
- builder.AppendLine();
-
- foreach (var link in links)
- {
- if (!nodesById.TryGetValue(link.ToNodeId, out var target))
- continue;
-
- var label = string.IsNullOrWhiteSpace(link.Label)
- ? string.Empty
- : $" ({EscapeMarkdownInline(CleanSingleLine(link.Label, string.Empty))})";
- builder.AppendLine($"- {EscapeMarkdownInline(BuildTextNodeReference(target, nodeIndexes))}{label}");
- }
- }
-
- private static void AppendMarkdownConnections(
- StringBuilder builder,
- WorkflowDefinition workflow,
- IReadOnlyDictionary nodeIndexes)
- {
- var nodesById = workflow.Nodes.ToDictionary(item => item.Id, StringComparer.Ordinal);
- var links = workflow.Links
- .Where(link => nodesById.ContainsKey(link.FromNodeId) && nodesById.ContainsKey(link.ToNodeId))
- .ToList();
-
- builder.AppendLine();
- builder.AppendLine("## Connections");
- builder.AppendLine();
-
- if (links.Count == 0)
- {
- builder.AppendLine("No connections.");
- return;
- }
-
- foreach (var link in links)
- {
- var from = EscapeMarkdownInline(BuildTextNodeReference(nodesById[link.FromNodeId], nodeIndexes));
- var to = EscapeMarkdownInline(BuildTextNodeReference(nodesById[link.ToNodeId], nodeIndexes));
- var label = string.IsNullOrWhiteSpace(link.Label)
- ? string.Empty
- : $" ({EscapeMarkdownInline(CleanSingleLine(link.Label, string.Empty))})";
- builder.AppendLine($"- **{from}** -> **{to}**{label}");
- }
- }
-
- private static string EscapeMarkdownInline(string value)
- {
- return value
- .Replace("\\", "\\\\", StringComparison.Ordinal)
- .Replace("*", "\\*", StringComparison.Ordinal)
- .Replace("_", "\\_", StringComparison.Ordinal)
- .Replace("[", "\\[", StringComparison.Ordinal)
- .Replace("]", "\\]", StringComparison.Ordinal);
- }
-}
diff --git a/src/ColumnPadStudio/Services/WorkflowService.ReadableTextExports.cs b/src/ColumnPadStudio/Services/WorkflowService.ReadableTextExports.cs
index c6a2f23..43dbc36 100644
--- a/src/ColumnPadStudio/Services/WorkflowService.ReadableTextExports.cs
+++ b/src/ColumnPadStudio/Services/WorkflowService.ReadableTextExports.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using System.Text;
using ColumnPadStudio.Workflows;
@@ -17,10 +18,10 @@ public string BuildTextExport(WorkflowDefinition workflow)
var builder = new StringBuilder();
builder.AppendLine(TextExportMarker);
builder.AppendLine(TextExportFormatLine);
+ builder.AppendLine("Readable copy only; import the .workflow.json file to continue editing.");
builder.AppendLine();
- builder.AppendLine($"Workflow: {CleanSingleLine(export.Name, "New Workflow")}");
- builder.AppendLine($"Category: {CleanSingleLine(export.Category, "Custom")}");
- builder.AppendLine($"Trigger: {export.Trigger}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $"Workflow: {CleanSingleLine(export.Name, "New Workflow")}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $"Category: {CleanSingleLine(export.Category, "Custom")}");
AppendTextBlock(builder, "Description", export.Description);
builder.AppendLine();
@@ -29,7 +30,7 @@ public string BuildTextExport(WorkflowDefinition workflow)
foreach (var node in orderedNodes)
{
- builder.AppendLine($"{nodeIndexes[node.Id]}. [{node.Kind}] {CleanSingleLine(node.Title, WorkflowDiagramNode.DefaultTitleForKind(node.Kind))}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $"{nodeIndexes[node.Id]}. [{node.Kind}] {CleanSingleLine(node.Title, WorkflowDiagramNode.DefaultTitleForKind(node.Kind))}");
AppendTextBlock(builder, "Description", node.Description);
AppendTextBlock(builder, "Goal", node.Goal);
AppendTextBlock(builder, "Instructions", node.Instructions);
@@ -39,7 +40,6 @@ public string BuildTextExport(WorkflowDefinition workflow)
builder.AppendLine();
}
- AppendTextConnections(builder, export, nodeIndexes);
return builder.ToString().TrimEnd() + Environment.NewLine;
}
@@ -49,9 +49,9 @@ private static void AppendTextBlock(StringBuilder builder, string label, string?
if (text.Length == 0)
return;
- builder.AppendLine($"{label}:");
+ builder.AppendLine(CultureInfo.InvariantCulture, $"{label}:");
foreach (var line in text.Split('\n'))
- builder.AppendLine($" {line}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $" {line}");
}
private static void AppendTextChecklist(StringBuilder builder, WorkflowDiagramNode node)
@@ -67,7 +67,7 @@ private static void AppendTextChecklist(StringBuilder builder, WorkflowDiagramNo
foreach (var item in items)
{
var marker = item.IsDone ? "[x]" : "[ ]";
- builder.AppendLine($" - {marker} {CleanSingleLine(item.Text, "Checklist item")}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $" - {marker} {CleanSingleLine(item.Text, "Checklist item")}");
}
}
@@ -92,37 +92,8 @@ private static void AppendTextNextSteps(
var label = string.IsNullOrWhiteSpace(link.Label)
? string.Empty
: $" ({CleanSingleLine(link.Label, string.Empty)})";
- builder.AppendLine($" - {BuildTextNodeReference(target, nodeIndexes)}{label}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $" - {BuildTextNodeReference(target, nodeIndexes)}{label}");
}
}
- private static void AppendTextConnections(
- StringBuilder builder,
- WorkflowDefinition workflow,
- IReadOnlyDictionary nodeIndexes)
- {
- var nodesById = workflow.Nodes.ToDictionary(item => item.Id, StringComparer.Ordinal);
- var links = workflow.Links
- .Where(link => nodesById.ContainsKey(link.FromNodeId) && nodesById.ContainsKey(link.ToNodeId))
- .ToList();
-
- builder.AppendLine("Connections");
- builder.AppendLine("-----------");
-
- if (links.Count == 0)
- {
- builder.AppendLine("No connections.");
- return;
- }
-
- foreach (var link in links)
- {
- var from = BuildTextNodeReference(nodesById[link.FromNodeId], nodeIndexes);
- var to = BuildTextNodeReference(nodesById[link.ToNodeId], nodeIndexes);
- var label = string.IsNullOrWhiteSpace(link.Label)
- ? string.Empty
- : $" ({CleanSingleLine(link.Label, string.Empty)})";
- builder.AppendLine($"- {from} -> {to}{label}");
- }
- }
}
diff --git a/src/ColumnPadStudio/Services/WorkflowService.Serialization.cs b/src/ColumnPadStudio/Services/WorkflowService.Serialization.cs
index c8f62fd..c2dc42d 100644
--- a/src/ColumnPadStudio/Services/WorkflowService.Serialization.cs
+++ b/src/ColumnPadStudio/Services/WorkflowService.Serialization.cs
@@ -18,17 +18,37 @@ public static bool IsWorkflowDefinitionJson(string? json)
return false;
var root = document.RootElement;
- if (TryGetPropertyIgnoreCase(root, nameof(WorkflowDefinition.FileType), out var fileType) &&
+ var schemaVersion = TryGetPropertyIgnoreCase(root, nameof(WorkflowDefinition.SchemaVersion), out var schemaVersionNode) &&
+ schemaVersionNode.ValueKind == JsonValueKind.Number &&
+ schemaVersionNode.TryGetInt32(out var parsedSchemaVersion)
+ ? parsedSchemaVersion
+ : 1;
+
+ if (schemaVersion < 1 || schemaVersion > WorkflowDefinition.CurrentSchemaVersion)
+ return false;
+
+ var hasFileType = TryGetPropertyIgnoreCase(root, nameof(WorkflowDefinition.FileType), out var fileType);
+ if (hasFileType &&
fileType.ValueKind == JsonValueKind.String &&
!string.Equals(fileType.GetString(), WorkflowDefinition.WorkflowFileType, StringComparison.OrdinalIgnoreCase))
{
return false;
}
- return TryGetPropertyIgnoreCase(root, nameof(WorkflowDefinition.Nodes), out var nodes) &&
- nodes.ValueKind == JsonValueKind.Array &&
- TryGetPropertyIgnoreCase(root, nameof(WorkflowDefinition.Links), out var links) &&
- links.ValueKind == JsonValueKind.Array;
+ if (hasFileType && fileType.ValueKind != JsonValueKind.String)
+ return false;
+
+ if (schemaVersion >= WorkflowDefinition.CurrentSchemaVersion && !hasFileType)
+ return false;
+
+ var hasCurrentDiagram = TryGetPropertyIgnoreCase(root, nameof(WorkflowDefinition.Nodes), out var nodes) &&
+ nodes.ValueKind == JsonValueKind.Array &&
+ TryGetPropertyIgnoreCase(root, nameof(WorkflowDefinition.Links), out var links) &&
+ links.ValueKind == JsonValueKind.Array;
+ var hasLegacySteps = TryGetPropertyIgnoreCase(root, "Steps", out var steps) &&
+ steps.ValueKind == JsonValueKind.Array;
+
+ return hasCurrentDiagram || hasLegacySteps;
}
catch (JsonException)
{
@@ -45,7 +65,6 @@ private static WorkflowDefinition Snapshot(WorkflowDefinition source)
Name = source.Name,
Category = source.Category,
Description = source.Description,
- Trigger = source.Trigger,
Nodes = new ObservableCollection(
source.Nodes.Select(node => new WorkflowDiagramNode
{
@@ -76,7 +95,7 @@ private static WorkflowDefinition Snapshot(WorkflowDefinition source)
private static void Normalize(WorkflowDefinition workflow, string? fallbackName)
{
- workflow.SchemaVersion = Math.Max(3, workflow.SchemaVersion);
+ workflow.SchemaVersion = WorkflowDefinition.CurrentSchemaVersion;
workflow.Id = string.IsNullOrWhiteSpace(workflow.Id)
? Guid.NewGuid().ToString("N")
: workflow.Id.Trim();
diff --git a/src/ColumnPadStudio/Services/WorkflowService.cs b/src/ColumnPadStudio/Services/WorkflowService.cs
index 99c7c88..937a8fb 100644
--- a/src/ColumnPadStudio/Services/WorkflowService.cs
+++ b/src/ColumnPadStudio/Services/WorkflowService.cs
@@ -10,7 +10,6 @@ public sealed partial class WorkflowService
{
public const string TextExportMarker = "ColumnPad Workflow Export";
public const string TextExportFormatLine = "Format: Text";
- public const string MarkdownExportMarker = "";
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -67,7 +66,7 @@ public bool TryLoad(string filePath, out WorkflowDefinition workflow)
if (!IsWorkflowDefinitionJson(json))
return false;
- var parsed = JsonSerializer.Deserialize(json, JsonOptions);
+ var parsed = DeserializeWorkflow(json);
if (parsed is null)
return false;
@@ -146,14 +145,6 @@ public void ExportTextToPath(WorkflowDefinition workflow, string filePath)
AtomicFileWriter.WriteText(filePath, BuildTextExport(workflow), Encoding.UTF8);
}
- public void ExportMarkdownToPath(WorkflowDefinition workflow, string filePath)
- {
- ArgumentNullException.ThrowIfNull(workflow);
- ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
-
- AtomicFileWriter.WriteText(filePath, BuildMarkdownExport(workflow), Encoding.UTF8);
- }
-
public WorkflowDefinition CreateDraftFromImportedWorkflow(WorkflowDefinition imported, string? sourceLabel = null)
{
ArgumentNullException.ThrowIfNull(imported);
diff --git a/src/ColumnPadStudio/Services/WorkspaceRecoveryStore.cs b/src/ColumnPadStudio/Services/WorkspaceRecoveryStore.cs
index 9bb55e5..3f9a3f1 100644
--- a/src/ColumnPadStudio/Services/WorkspaceRecoveryStore.cs
+++ b/src/ColumnPadStudio/Services/WorkspaceRecoveryStore.cs
@@ -10,7 +10,9 @@ public sealed record WorkspaceRecoveryWorkspace(
string? CurrentFilePath,
SaveFileKind CurrentFileKind,
bool IsDirty,
- bool RequiresSaveAsBeforeOverwrite);
+ bool RequiresSaveAsBeforeOverwrite,
+ int LastMultiColumnCount = 3,
+ bool HasSessionChanges = false);
public sealed record WorkspaceRecoverySnapshot(
DateTime SavedUtc,
@@ -19,60 +21,101 @@ public sealed record WorkspaceRecoverySnapshot(
public static class WorkspaceRecoveryStore
{
+ private const string RecoveryFileType = "ColumnPadRecovery";
+ private const int CurrentManifestVersion = 2;
+ private const string CurrentGenerationFileName = "current-generation.txt";
+ private const string GenerationPrefix = "generation-";
+ private const string PendingPrefix = ".pending-";
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public static string RecoveryDirectory => AppStoragePaths.RecoveryDirectory;
- public static void Save(IReadOnlyList workspaces, int activeWorkspaceIndex, string? recoveryDirectory = null)
+ public static void Save(
+ IReadOnlyList workspaces,
+ int activeWorkspaceIndex,
+ string? recoveryDirectory = null,
+ CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(workspaces);
+ cancellationToken.ThrowIfCancellationRequested();
if (workspaces.Count == 0)
{
- Clear(recoveryDirectory);
+ Clear(recoveryDirectory, cancellationToken);
return;
}
+ if (workspaces.Count > WorkspaceSessionFileService.MaxWorkspaces)
+ {
+ throw new ArgumentException(
+ $"Recovery can contain up to {WorkspaceSessionFileService.MaxWorkspaces} workspaces.",
+ nameof(workspaces));
+ }
+
var root = GetRecoveryDirectory(recoveryDirectory);
+ cancellationToken.ThrowIfCancellationRequested();
Directory.CreateDirectory(root);
+ cancellationToken.ThrowIfCancellationRequested();
var normalizedActiveIndex = Math.Clamp(activeWorkspaceIndex, 0, workspaces.Count - 1);
+ var generationId = $"{DateTime.UtcNow:yyyyMMddHHmmssfffffff}-{Guid.NewGuid():N}";
+ var generationName = GenerationPrefix + generationId;
+ var pendingDirectory = Path.Combine(root, PendingPrefix + generationId);
+ var generationDirectory = Path.Combine(root, generationName);
var manifestEntries = new List(workspaces.Count);
- var writtenFiles = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var pointerActivated = false;
- for (var i = 0; i < workspaces.Count; i++)
+ try
{
- var workspace = workspaces[i];
- var fileName = $"workspace-{i + 1}.columnpad.json";
- var filePath = Path.Combine(root, fileName);
+ Directory.CreateDirectory(pendingDirectory);
+ cancellationToken.ThrowIfCancellationRequested();
+ for (var i = 0; i < workspaces.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var workspace = workspaces[i];
+ var fileName = $"workspace-{i + 1}.columnpad.json";
- AtomicFileWriter.WriteText(filePath, workspace.LayoutJson);
- manifestEntries.Add(new RecoveryManifestWorkspace(
- Name: string.IsNullOrWhiteSpace(workspace.Name) ? $"Workspace {i + 1}" : workspace.Name.Trim(),
- FileName: fileName,
- CurrentFilePath: workspace.CurrentFilePath,
- CurrentFileKind: workspace.CurrentFileKind.ToString(),
- IsDirty: workspace.IsDirty,
- RequiresSaveAsBeforeOverwrite: workspace.RequiresSaveAsBeforeOverwrite));
- writtenFiles.Add(fileName);
- }
+ AtomicFileWriter.WriteText(Path.Combine(pendingDirectory, fileName), workspace.LayoutJson);
+ cancellationToken.ThrowIfCancellationRequested();
+ manifestEntries.Add(new RecoveryManifestWorkspace(
+ Name: string.IsNullOrWhiteSpace(workspace.Name) ? $"Workspace {i + 1}" : workspace.Name.Trim(),
+ FileName: fileName,
+ CurrentFilePath: workspace.CurrentFilePath,
+ CurrentFileKind: workspace.CurrentFileKind.ToString(),
+ IsDirty: workspace.IsDirty,
+ RequiresSaveAsBeforeOverwrite: workspace.RequiresSaveAsBeforeOverwrite,
+ LastMultiColumnCount: workspace.LastMultiColumnCount,
+ HasSessionChanges: workspace.HasSessionChanges));
+ }
- foreach (var staleFilePath in Directory.GetFiles(root, "workspace-*.columnpad.json"))
- {
- var fileName = Path.GetFileName(staleFilePath);
- if (!writtenFiles.Contains(fileName))
- File.Delete(staleFilePath);
- }
+ var manifest = new RecoveryManifest(
+ FileType: RecoveryFileType,
+ Version: CurrentManifestVersion,
+ SavedUtc: DateTime.UtcNow,
+ ActiveWorkspaceIndex: normalizedActiveIndex,
+ Workspaces: manifestEntries);
- var manifest = new RecoveryManifest(
- Version: 1,
- SavedUtc: DateTime.UtcNow,
- ActiveWorkspaceIndex: normalizedActiveIndex,
- Workspaces: manifestEntries);
+ cancellationToken.ThrowIfCancellationRequested();
+ var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
+ cancellationToken.ThrowIfCancellationRequested();
+ AtomicFileWriter.WriteText(
+ Path.Combine(pendingDirectory, "manifest.json"),
+ manifestJson);
+ cancellationToken.ThrowIfCancellationRequested();
- AtomicFileWriter.WriteText(
- Path.Combine(root, "manifest.json"),
- JsonSerializer.Serialize(manifest, JsonOptions));
+ // A cancelled write must never activate an incomplete generation.
+ Directory.Move(pendingDirectory, generationDirectory);
+ cancellationToken.ThrowIfCancellationRequested();
+ AtomicFileWriter.WriteText(Path.Combine(root, CurrentGenerationFileName), generationName);
+ pointerActivated = true;
+ CleanupOldGenerations(root, generationDirectory);
+ }
+ finally
+ {
+ TryDeleteDirectory(pendingDirectory);
+ if (!pointerActivated)
+ TryDeleteDirectory(generationDirectory);
+ }
}
public static bool TryLoad(out WorkspaceRecoverySnapshot snapshot, string? recoveryDirectory = null)
@@ -80,73 +123,247 @@ public static bool TryLoad(out WorkspaceRecoverySnapshot snapshot, string? recov
snapshot = new WorkspaceRecoverySnapshot(DateTime.MinValue, 0, Array.Empty());
var root = GetRecoveryDirectory(recoveryDirectory);
- var manifestPath = Path.Combine(root, "manifest.json");
- if (!File.Exists(manifestPath))
+ if (!Directory.Exists(root))
return false;
- RecoveryManifest? manifest;
+ foreach (var generationDirectory in GetGenerationCandidates(root))
+ {
+ if (TryLoadSnapshot(generationDirectory, requireCompleteSnapshot: true, out snapshot))
+ return true;
+ }
+
+ // Version 1 stored its manifest and workspace files directly in the root.
+ return TryLoadSnapshot(root, requireCompleteSnapshot: false, out snapshot);
+ }
+
+ public static void Clear(
+ string? recoveryDirectory = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var root = GetRecoveryDirectory(recoveryDirectory);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (Directory.Exists(root))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ public static bool TryClear(
+ string? recoveryDirectory = null,
+ CancellationToken cancellationToken = default)
+ {
try
{
- manifest = JsonSerializer.Deserialize(File.ReadAllText(manifestPath));
+ Clear(recoveryDirectory, cancellationToken);
+ return true;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return false;
}
- catch (JsonException)
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return false;
}
+ }
+
+ private static string GetRecoveryDirectory(string? recoveryDirectory)
+ {
+ return string.IsNullOrWhiteSpace(recoveryDirectory) ? RecoveryDirectory : recoveryDirectory;
+ }
+
+ private static IEnumerable GetGenerationCandidates(string root)
+ {
+ var yielded = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var pointerPath = Path.Combine(root, CurrentGenerationFileName);
+ string? pointedGenerationPath = null;
+
+ try
+ {
+ if (File.Exists(pointerPath))
+ {
+ var generationName = File.ReadAllText(pointerPath).Trim();
+ if (IsSafeGenerationName(generationName))
+ {
+ var generationPath = Path.Combine(root, generationName);
+ if (Directory.Exists(generationPath))
+ pointedGenerationPath = generationPath;
+ }
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Fall through to complete generations already present on disk.
+ }
+
+ if (pointedGenerationPath is not null && yielded.Add(pointedGenerationPath))
+ yield return pointedGenerationPath;
+
+ IReadOnlyList generationDirectories;
+ try
+ {
+ generationDirectories = Directory
+ .GetDirectories(root, GenerationPrefix + "*")
+ .OrderByDescending(Directory.GetLastWriteTimeUtc)
+ .ToList();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ generationDirectories = Array.Empty();
+ }
+
+ foreach (var generationDirectory in generationDirectories)
+ {
+ if (yielded.Add(generationDirectory))
+ yield return generationDirectory;
+ }
+ }
- if (manifest is null || manifest.Workspaces.Count == 0)
+ private static bool TryLoadSnapshot(
+ string directory,
+ bool requireCompleteSnapshot,
+ out WorkspaceRecoverySnapshot snapshot)
+ {
+ snapshot = new WorkspaceRecoverySnapshot(DateTime.MinValue, 0, Array.Empty());
+ var manifestPath = Path.Combine(directory, "manifest.json");
+ if (!File.Exists(manifestPath))
return false;
- var workspaces = new List(manifest.Workspaces.Count);
- foreach (var entry in manifest.Workspaces)
+ try
{
- if (string.IsNullOrWhiteSpace(entry.FileName))
- continue;
+ var manifest = JsonSerializer.Deserialize(File.ReadAllText(manifestPath));
+ if (manifest?.Workspaces is null ||
+ manifest.Workspaces.Count == 0 ||
+ manifest.Workspaces.Count > WorkspaceSessionFileService.MaxWorkspaces)
+ {
+ return false;
+ }
- var filePath = Path.Combine(root, entry.FileName);
- if (!File.Exists(filePath))
- continue;
+ if (manifest.Version < 1 || manifest.Version > CurrentManifestVersion)
+ return false;
- var layoutJson = File.ReadAllText(filePath);
- if (string.IsNullOrWhiteSpace(layoutJson))
- continue;
+ if (manifest.Version >= CurrentManifestVersion &&
+ !string.Equals(manifest.FileType, RecoveryFileType, StringComparison.Ordinal))
+ {
+ return false;
+ }
- var kind = Enum.TryParse(entry.CurrentFileKind, ignoreCase: true, out var parsedKind)
- ? parsedKind
- : SaveFileKind.Layout;
+ var workspaces = new List(manifest.Workspaces.Count);
+ foreach (var entry in manifest.Workspaces)
+ {
+ if (!IsSafeWorkspaceFileName(entry.FileName))
+ return false;
- workspaces.Add(new WorkspaceRecoveryWorkspace(
- Name: string.IsNullOrWhiteSpace(entry.Name) ? $"Workspace {workspaces.Count + 1}" : entry.Name.Trim(),
- LayoutJson: layoutJson,
- CurrentFilePath: entry.CurrentFilePath,
- CurrentFileKind: kind,
- IsDirty: entry.IsDirty,
- RequiresSaveAsBeforeOverwrite: entry.RequiresSaveAsBeforeOverwrite));
- }
+ var filePath = Path.Combine(directory, entry.FileName);
+ if (!File.Exists(filePath))
+ {
+ if (requireCompleteSnapshot)
+ return false;
- if (workspaces.Count == 0)
+ continue;
+ }
+
+ var layoutJson = File.ReadAllText(filePath);
+ if (string.IsNullOrWhiteSpace(layoutJson))
+ {
+ if (requireCompleteSnapshot)
+ return false;
+
+ continue;
+ }
+
+ var retiredMarkdownFileKind = string.Equals(entry.CurrentFileKind, "MarkdownDocument", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(entry.CurrentFileKind, "MarkdownExport", StringComparison.OrdinalIgnoreCase);
+ var kind = !retiredMarkdownFileKind &&
+ Enum.TryParse(entry.CurrentFileKind, ignoreCase: true, out var parsedKind) &&
+ Enum.IsDefined(parsedKind)
+ ? parsedKind
+ : SaveFileKind.Layout;
+
+ workspaces.Add(new WorkspaceRecoveryWorkspace(
+ Name: string.IsNullOrWhiteSpace(entry.Name) ? $"Workspace {workspaces.Count + 1}" : entry.Name.Trim(),
+ LayoutJson: layoutJson,
+ CurrentFilePath: retiredMarkdownFileKind ? null : entry.CurrentFilePath,
+ CurrentFileKind: kind,
+ IsDirty: entry.IsDirty,
+ RequiresSaveAsBeforeOverwrite: retiredMarkdownFileKind ? false : entry.RequiresSaveAsBeforeOverwrite,
+ LastMultiColumnCount: Math.Max(2, entry.LastMultiColumnCount),
+ HasSessionChanges: entry.HasSessionChanges));
+ }
+
+ if (workspaces.Count == 0)
+ return false;
+
+ snapshot = new WorkspaceRecoverySnapshot(
+ SavedUtc: manifest.SavedUtc,
+ ActiveWorkspaceIndex: Math.Clamp(manifest.ActiveWorkspaceIndex, 0, workspaces.Count - 1),
+ Workspaces: workspaces);
+ return true;
+ }
+ catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
+ {
return false;
+ }
+ }
- snapshot = new WorkspaceRecoverySnapshot(
- SavedUtc: manifest.SavedUtc,
- ActiveWorkspaceIndex: Math.Clamp(manifest.ActiveWorkspaceIndex, 0, workspaces.Count - 1),
- Workspaces: workspaces);
- return true;
+ private static bool IsSafeGenerationName(string generationName)
+ {
+ return generationName.StartsWith(GenerationPrefix, StringComparison.Ordinal) &&
+ string.Equals(Path.GetFileName(generationName), generationName, StringComparison.Ordinal);
}
- public static void Clear(string? recoveryDirectory = null)
+ private static bool IsSafeWorkspaceFileName(string? fileName)
{
- var root = GetRecoveryDirectory(recoveryDirectory);
- if (Directory.Exists(root))
- Directory.Delete(root, recursive: true);
+ return !string.IsNullOrWhiteSpace(fileName) &&
+ string.Equals(Path.GetFileName(fileName), fileName, StringComparison.OrdinalIgnoreCase) &&
+ fileName.StartsWith("workspace-", StringComparison.OrdinalIgnoreCase) &&
+ fileName.EndsWith(".columnpad.json", StringComparison.OrdinalIgnoreCase);
}
- private static string GetRecoveryDirectory(string? recoveryDirectory)
+ private static void CleanupOldGenerations(string root, string currentGenerationDirectory)
{
- return string.IsNullOrWhiteSpace(recoveryDirectory) ? RecoveryDirectory : recoveryDirectory;
+ var generationDirectories = Directory
+ .GetDirectories(root, GenerationPrefix + "*")
+ .OrderByDescending(Directory.GetLastWriteTimeUtc)
+ .ToList();
+
+ var retained = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ currentGenerationDirectory
+ };
+
+ var previousGeneration = generationDirectories
+ .FirstOrDefault(path => !string.Equals(path, currentGenerationDirectory, StringComparison.OrdinalIgnoreCase));
+ if (previousGeneration is not null)
+ retained.Add(previousGeneration);
+
+ foreach (var generationDirectory in generationDirectories)
+ {
+ if (!retained.Contains(generationDirectory))
+ TryDeleteDirectory(generationDirectory);
+ }
+
+ foreach (var pendingDirectory in Directory.GetDirectories(root, PendingPrefix + "*"))
+ TryDeleteDirectory(pendingDirectory);
+ }
+
+ private static void TryDeleteDirectory(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ Directory.Delete(path, recursive: true);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Recovery cleanup is best effort; valid generations must remain usable.
+ }
}
private sealed record RecoveryManifest(
+ string? FileType,
int Version,
DateTime SavedUtc,
int ActiveWorkspaceIndex,
@@ -158,5 +375,7 @@ private sealed record RecoveryManifestWorkspace(
string? CurrentFilePath,
string CurrentFileKind,
bool IsDirty,
- bool RequiresSaveAsBeforeOverwrite);
+ bool RequiresSaveAsBeforeOverwrite,
+ int LastMultiColumnCount = 3,
+ bool HasSessionChanges = false);
}
diff --git a/src/ColumnPadStudio/Services/WorkspaceSessionFileService.cs b/src/ColumnPadStudio/Services/WorkspaceSessionFileService.cs
index 88591af..5a5e5ed 100644
--- a/src/ColumnPadStudio/Services/WorkspaceSessionFileService.cs
+++ b/src/ColumnPadStudio/Services/WorkspaceSessionFileService.cs
@@ -18,6 +18,7 @@ public sealed record WorkspaceSessionSaveCandidate(
public static class WorkspaceSessionFileService
{
+ public const int MaxWorkspaces = 64;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public static bool ShouldSaveWorkspaceSession(IReadOnlyList workspaces)
@@ -76,15 +77,14 @@ public static bool IsExistingWorkspaceSessionFile(string? path)
public static bool IsWorkspaceSessionJson(string? json)
{
- return WorkspaceImportRules.IsWorkspaceSessionJson(json);
+ return TryParseSession(json, out _);
}
public static string SerializeSession(IReadOnlyList workspaces, int activeWorkspaceIndex)
{
ArgumentNullException.ThrowIfNull(workspaces);
- if (workspaces.Count == 0)
- throw new ArgumentException("At least one workspace is required.", nameof(workspaces));
+ ValidateWorkspaceCount(workspaces.Count, nameof(workspaces));
var normalized = new List(workspaces.Count);
for (var i = 0; i < workspaces.Count; i++)
@@ -98,8 +98,8 @@ public static string SerializeSession(IReadOnlyList w
}
var session = new WorkspaceSessionFile(
- FileType: "ColumnPadWorkspaceSession",
- Version: 2,
+ FileType: WorkspaceImportRules.WorkspaceSessionFileType,
+ Version: WorkspaceImportRules.CurrentWorkspaceSessionVersion,
ActiveWorkspaceIndex: Math.Clamp(activeWorkspaceIndex, 0, normalized.Count - 1),
Workspaces: normalized);
@@ -123,9 +123,16 @@ public static bool TryParseSession(string? json, out WorkspaceSessionData sessio
return false;
}
- if (parsed?["Workspaces"] is not JsonArray workspaceNodes || workspaceNodes.Count == 0)
+ if (parsed is null || !WorkspaceImportRules.IsWorkspaceSessionJson(json))
return false;
+ if (parsed["Workspaces"] is not JsonArray workspaceNodes ||
+ workspaceNodes.Count == 0 ||
+ workspaceNodes.Count > MaxWorkspaces)
+ {
+ return false;
+ }
+
var workspaces = new List(workspaceNodes.Count);
for (var i = 0; i < workspaceNodes.Count; i++)
{
@@ -149,6 +156,15 @@ public static bool TryParseSession(string? json, out WorkspaceSessionData sessio
return true;
}
+ private static void ValidateWorkspaceCount(int workspaceCount, string parameterName)
+ {
+ if (workspaceCount <= 0)
+ throw new ArgumentException("At least one workspace is required.", parameterName);
+
+ if (workspaceCount > MaxWorkspaces)
+ throw new ArgumentException($"A session can contain up to {MaxWorkspaces} workspaces.", parameterName);
+ }
+
private static JsonNode ParseLayoutNode(string? layoutJson)
{
if (string.IsNullOrWhiteSpace(layoutJson))
diff --git a/src/ColumnPadStudio/ViewModels/ColumnImageViewModel.cs b/src/ColumnPadStudio/ViewModels/ColumnImageViewModel.cs
index 5886e94..58c5784 100644
--- a/src/ColumnPadStudio/ViewModels/ColumnImageViewModel.cs
+++ b/src/ColumnPadStudio/ViewModels/ColumnImageViewModel.cs
@@ -1,7 +1,7 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
-using System.Windows.Media.Imaging;
+using ColumnPadStudio.Services;
namespace ColumnPadStudio.ViewModels;
@@ -10,9 +10,10 @@ public sealed class ColumnImageViewModel : NotifyBase
public const double MinDisplayWidth = 80.0;
public const double MaxDisplayWidth = 2000.0;
- private string _filePath;
+ private readonly string _filePath;
+ private readonly byte[]? _imageContent;
private string _originalFileName;
- private ImageSource? _displaySource;
+ private readonly ImageSource? _displaySource;
private double _width;
private double _left;
private double _top;
@@ -27,41 +28,30 @@ public ColumnImageViewModel(
int pixelHeight = 0,
double left = 12.0,
double top = 12.0,
- ColumnImageLayer layer = ColumnImageLayer.InFrontOfText)
+ ColumnImageLayer layer = ColumnImageLayer.InFrontOfText,
+ byte[]? imageContent = null)
{
Id = Guid.NewGuid().ToString("N");
- _filePath = filePath;
- _displaySource = LoadDisplaySource(filePath);
+ _filePath = filePath ?? string.Empty;
+ _imageContent = imageContent is { Length: > 0 and <= ColumnImageFileService.MaxImageFileBytes }
+ ? imageContent
+ : ColumnImageFileService.TryReadImageContent(filePath);
_originalFileName = string.IsNullOrWhiteSpace(originalFileName)
- ? Path.GetFileName(filePath)
+ ? Path.GetFileName(filePath) ?? "Picture"
: originalFileName.Trim();
_width = ClampWidth(width);
_left = ClampPosition(left);
_top = ClampPosition(top);
_layer = layer;
- PixelWidth = Math.Max(0, pixelWidth);
- PixelHeight = Math.Max(0, pixelHeight);
+ var display = ColumnImageFileService.LoadDisplaySource(_imageContent, _filePath);
+ PixelWidth = display?.PixelWidth ?? Math.Max(0, pixelWidth);
+ PixelHeight = display?.PixelHeight ?? Math.Max(0, pixelHeight);
+ _displaySource = display?.Source;
}
public string Id { get; }
- public string FilePath
- {
- get => _filePath;
- set
- {
- var nextValue = value ?? string.Empty;
- if (string.Equals(_filePath, nextValue, StringComparison.Ordinal))
- return;
-
- _filePath = nextValue;
- _displaySource = LoadDisplaySource(nextValue);
- OnPropertyChanged();
- OnPropertyChanged(nameof(DisplayName));
- OnPropertyChanged(nameof(DisplaySource));
- OnPropertyChanged(nameof(CanDisplayImage));
- }
- }
+ public string FilePath => _filePath;
public string OriginalFileName
{
@@ -142,6 +132,7 @@ public bool IsSelected
public int PixelWidth { get; }
public int PixelHeight { get; }
+ internal byte[]? ImageContent => _imageContent;
public ImageSource? DisplaySource => _displaySource;
@@ -166,7 +157,7 @@ public bool IsSelected
: "Place In Front of Text";
public ColumnImageViewModel Duplicate()
- => new(FilePath, OriginalFileName, Width, PixelWidth, PixelHeight, Left, Top, Layer);
+ => new(FilePath, OriginalFileName, Width, PixelWidth, PixelHeight, Left, Top, Layer, _imageContent);
private static double ClampWidth(double width)
{
@@ -183,30 +174,4 @@ private static double ClampPosition(double value)
return Math.Max(0.0, value);
}
-
- private static ImageSource? LoadDisplaySource(string filePath)
- {
- if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
- return null;
-
- try
- {
- using var stream = File.OpenRead(filePath);
- var decoder = BitmapDecoder.Create(
- stream,
- BitmapCreateOptions.PreservePixelFormat,
- BitmapCacheOption.OnLoad);
- var frame = decoder.Frames.FirstOrDefault();
- frame?.Freeze();
- return frame;
- }
- catch (Exception ex) when (ex is IOException
- or UnauthorizedAccessException
- or NotSupportedException
- or FormatException
- or InvalidOperationException)
- {
- return null;
- }
- }
}
diff --git a/src/ColumnPadStudio/ViewModels/ColumnViewModel.Checklists.cs b/src/ColumnPadStudio/ViewModels/ColumnViewModel.Checklists.cs
index db2d235..dca5195 100644
--- a/src/ColumnPadStudio/ViewModels/ColumnViewModel.Checklists.cs
+++ b/src/ColumnPadStudio/ViewModels/ColumnViewModel.Checklists.cs
@@ -28,6 +28,7 @@ public void SetCheckedChecklistLineIndexes(IEnumerable? lineIndexes)
_checkedChecklistLineIndexes = next;
TrimChecklistLineIndexesToBounds();
+ InvalidateGutterState();
RecomputeDerivedMetrics();
}
@@ -43,6 +44,7 @@ public void ToggleChecklistLineChecked(int lineIndex)
_checkedChecklistLineIndexes.Add(lineIndex);
TrimChecklistLineIndexesToBounds();
+ InvalidateGutterState();
RecomputeDerivedMetrics();
}
@@ -56,6 +58,7 @@ private void RemapChecklistLineIndexes(string? previousText, string? nextText)
if (newLines.Length == 1 && newLines[0].Length == 0)
{
_checkedChecklistLineIndexes.Clear();
+ InvalidateGutterState();
return;
}
@@ -104,7 +107,11 @@ private void RemapChecklistLineIndexes(string? previousText, string? nextText)
}
}
+ if (_checkedChecklistLineIndexes.SetEquals(remapped))
+ return;
+
_checkedChecklistLineIndexes = remapped;
+ InvalidateGutterState();
}
private void UpdateMetricsText()
@@ -127,7 +134,8 @@ private void RecomputeDerivedMetrics()
}
LineCount = lines;
- TrimChecklistLineIndexesToBounds();
+ if (TrimChecklistLineIndexesToBounds())
+ InvalidateGutterState();
var wordCount = 0;
var inWord = false;
@@ -176,10 +184,10 @@ private void RecomputeDerivedMetrics()
UpdateMetricsText();
}
- private void TrimChecklistLineIndexesToBounds()
+ private bool TrimChecklistLineIndexesToBounds()
{
var maxIndex = Math.Max(0, LineCount - 1);
- _checkedChecklistLineIndexes.RemoveWhere(index => index < 0 || index > maxIndex);
+ return _checkedChecklistLineIndexes.RemoveWhere(index => index < 0 || index > maxIndex) > 0;
}
private static string[] SplitLines(string? text)
diff --git a/src/ColumnPadStudio/ViewModels/ColumnViewModel.cs b/src/ColumnPadStudio/ViewModels/ColumnViewModel.cs
index 0538388..53059e5 100644
--- a/src/ColumnPadStudio/ViewModels/ColumnViewModel.cs
+++ b/src/ColumnPadStudio/ViewModels/ColumnViewModel.cs
@@ -2,25 +2,29 @@
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Media;
using ColumnPadStudio.Domain.Lists;
using ColumnPadStudio.Domain.Text;
+using ColumnPadStudio.Domain.Workspaces;
+using ColumnPadStudio.Services;
namespace ColumnPadStudio.ViewModels;
public sealed partial class ColumnViewModel : NotifyBase
{
- public const double VisibleLineNumberColumnWidth = 46.0;
-
private string _title = "Column";
private string _text = "";
private int? _widthPx;
private bool _showLineNumbers = true;
+ private int _sharedGutterWidthPx = MainViewModel.DefaultGutterWidthPx;
private bool _wordWrap;
private string _editorFontFamily = "Consolas";
private double _editorFontSize = 13;
private FontStyle _editorFontStyle = FontStyles.Normal;
private FontWeight _editorFontWeight = FontWeights.Normal;
+ private string _editorTextColor = ColumnTextColorService.ThemeDefault;
+ private SolidColorBrush? _customEditorTextColorBrush;
private bool _isWidthLocked;
private bool _canMoveLeft;
@@ -28,6 +32,7 @@ public sealed partial class ColumnViewModel : NotifyBase
private bool _isActive;
private bool _isRenaming;
private bool _isStandaloneDocument;
+ private bool _isWidthManagementEnabled = true;
private PasteListPreset _pastePreset = PasteListPreset.None;
private LineMarkerMode _lineMarkerMode = LineMarkerMode.Numbers;
private bool _useDefaultFont = true;
@@ -36,6 +41,7 @@ public sealed partial class ColumnViewModel : NotifyBase
private int _wordCount;
private int _checklistTotal;
private int _checklistDone;
+ private int _gutterStateVersion;
private string _metricsText = "0 words | 1 line";
private HashSet _checkedChecklistLineIndexes = [];
@@ -72,7 +78,7 @@ public string Text
public int? WidthPx
{
get => _widthPx;
- set => Set(ref _widthPx, value);
+ set => Set(ref _widthPx, NormalizeWidth(value));
}
public bool IsWidthLocked
@@ -120,6 +126,7 @@ public LineMarkerMode LineMarkerMode
_lineMarkerMode = value;
OnPropertyChanged();
+ InvalidateGutterState();
RecomputeDerivedMetrics();
}
}
@@ -133,7 +140,29 @@ public bool IsRenaming
public bool IsStandaloneDocument
{
get => _isStandaloneDocument;
- set => Set(ref _isStandaloneDocument, value);
+ set
+ {
+ if (_isStandaloneDocument == value)
+ return;
+
+ _isStandaloneDocument = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(WidthLockActionToolTip));
+ }
+ }
+
+ public bool IsWidthManagementEnabled
+ {
+ get => _isWidthManagementEnabled;
+ set
+ {
+ if (_isWidthManagementEnabled == value)
+ return;
+
+ _isWidthManagementEnabled = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(WidthLockActionToolTip));
+ }
}
public bool UseDefaultFont
@@ -193,15 +222,39 @@ public FontWeight EditorFontWeight
set => Set(ref _editorFontWeight, value);
}
+ public string EditorTextColor
+ {
+ get => _editorTextColor;
+ set
+ {
+ var normalized = ColumnTextColorService.Normalize(value);
+ if (string.Equals(_editorTextColor, normalized, StringComparison.Ordinal))
+ return;
+
+ _editorTextColor = normalized;
+ _customEditorTextColorBrush = ColumnTextColorService.CreateCustomBrush(normalized);
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(CustomEditorTextColorBrush));
+ OnPropertyChanged(nameof(HasCustomEditorTextColor));
+ }
+ }
+
+ public SolidColorBrush? CustomEditorTextColorBrush => _customEditorTextColorBrush;
+ public bool HasCustomEditorTextColor => _customEditorTextColorBrush is not null;
+
public Visibility ShowLineNumbersVisibility => ShowLineNumbers ? Visibility.Visible : Visibility.Collapsed;
- public GridLength LineNumberColumnWidth => ShowLineNumbers ? new GridLength(VisibleLineNumberColumnWidth) : new GridLength(0);
+ public GridLength LineNumberColumnWidth => ShowLineNumbers ? new GridLength(_sharedGutterWidthPx) : new GridLength(0);
public TextWrapping TextWrappingMode => WordWrap ? TextWrapping.Wrap : TextWrapping.NoWrap;
public ScrollBarVisibility HorizontalScrollBarMode => WordWrap ? ScrollBarVisibility.Disabled : ScrollBarVisibility.Auto;
public string WidthLockActionLabel => IsWidthLocked ? "Allow Resize" : "Freeze Width";
- public string WidthLockActionToolTip => IsWidthLocked
- ? "This column width is frozen. Click to allow drag resizing again."
- : "Freeze this column width so the splitter cannot resize it.";
+ public string WidthLockActionToolTip => !IsWidthManagementEnabled
+ ? IsStandaloneDocument
+ ? "Single Text Mode fills the window. Switch to Column Mode to resize or freeze columns."
+ : "Choose Standard or Custom column width to resize and freeze columns."
+ : IsWidthLocked
+ ? "This column width is frozen. Click to allow drag resizing again."
+ : "Freeze this column width so the splitter cannot resize it.";
public double LineNumberFontSize => Math.Max(8.0, EditorFontSize);
public double EditorLineHeight => Math.Max(15.0, Math.Round((EditorFontSize / 13.0) * 23.0, 2));
@@ -230,6 +283,8 @@ public int ChecklistDone
private set => Set(ref _checklistDone, value);
}
+ public int GutterStateVersion => _gutterStateVersion;
+
public string MetricsText
{
get => _metricsText;
@@ -246,4 +301,35 @@ public void SetVisibleLineCount(int lineCount)
UpdateMetricsText();
}
+ internal void SetSharedGutterWidth(int widthPx)
+ {
+ var normalized = Math.Clamp(
+ widthPx,
+ MainViewModel.MinimumGutterWidthPx,
+ MainViewModel.MaximumGutterWidthPx);
+ if (_sharedGutterWidthPx == normalized)
+ return;
+
+ _sharedGutterWidthPx = normalized;
+ OnPropertyChanged(nameof(LineNumberColumnWidth));
+ }
+
+ private static int? NormalizeWidth(int? widthPx)
+ {
+ if (widthPx is null or <= 0)
+ return null;
+
+ return WorkspaceConstraints.ClampColumnWidth(widthPx.Value);
+ }
+
+ private void InvalidateGutterState()
+ {
+ unchecked
+ {
+ _gutterStateVersion++;
+ }
+
+ OnPropertyChanged(nameof(GutterStateVersion));
+ }
+
}
diff --git a/src/ColumnPadStudio/ViewModels/MainViewModel.Columns.cs b/src/ColumnPadStudio/ViewModels/MainViewModel.Columns.cs
index a05d382..5fd2a73 100644
--- a/src/ColumnPadStudio/ViewModels/MainViewModel.Columns.cs
+++ b/src/ColumnPadStudio/ViewModels/MainViewModel.Columns.cs
@@ -86,6 +86,12 @@ public void SetColumnCount(int requestedCount)
public void AddColumn()
{
+ if (Columns.Count >= WorkspaceConstraints.MaxColumns)
+ {
+ StatusText = $"A workspace can contain up to {WorkspaceConstraints.MaxColumns} columns.";
+ return;
+ }
+
PromoteRawDocumentToLayoutIfNeeded(Columns.Count + 1);
Columns.Add(MakeColumn($"Column {Columns.Count + 1}"));
ActiveColumnId = Columns.Last().Id;
@@ -138,24 +144,32 @@ public bool KeepOnlyColumn(string columnId)
return true;
}
- public void ResetActiveColumnWidth()
+ public void ResetActiveColumnWidth(int defaultColumnWidthPx)
{
var active = GetActive();
if (active is null)
return;
+ var resolvedDefaultWidth = WorkspaceConstraints.ClampColumnWidth(defaultColumnWidthPx);
active.WidthPx = null;
+ active.IsWidthLocked = false;
+ NotifyActiveColumnActionPropertiesChanged();
RequestRebuildColumns?.Invoke(this, EventArgs.Empty);
- StatusText = "Selected column width reset.";
+ StatusText = $"Reset {active.Title} to the default {resolvedDefaultWidth}px width.";
}
- public void ResetAllColumnWidths()
+ public void ResetAllColumnWidths(int defaultColumnWidthPx)
{
+ var resolvedDefaultWidth = WorkspaceConstraints.ClampColumnWidth(defaultColumnWidthPx);
foreach (var c in Columns)
+ {
c.WidthPx = null;
+ c.IsWidthLocked = false;
+ }
+ NotifyActiveColumnActionPropertiesChanged();
RequestRebuildColumns?.Invoke(this, EventArgs.Empty);
- StatusText = "All column widths reset.";
+ StatusText = $"Reset all columns to the default {resolvedDefaultWidth}px width.";
}
public void SetActiveColumnWidth(int widthPx)
@@ -164,7 +178,7 @@ public void SetActiveColumnWidth(int widthPx)
if (active is null)
return;
- active.WidthPx = Math.Clamp(widthPx, 120, 5000);
+ active.WidthPx = WorkspaceConstraints.ClampColumnWidth(widthPx);
RequestRebuildColumns?.Invoke(this, EventArgs.Empty);
StatusText = $"Set {active.Title} width to {active.WidthPx}px.";
}
@@ -243,6 +257,12 @@ public void DuplicateActive()
var a = GetActive();
if (a is null) return;
+ if (Columns.Count >= WorkspaceConstraints.MaxColumns)
+ {
+ StatusText = $"A workspace can contain up to {WorkspaceConstraints.MaxColumns} columns.";
+ return;
+ }
+
PromoteRawDocumentToLayoutIfNeeded(Columns.Count + 1);
var copy = MakeColumn($"{a.Title} (copy)");
@@ -260,6 +280,7 @@ public void DuplicateActive()
copy.EditorFontStyle = a.EditorFontStyle;
copy.EditorFontWeight = a.EditorFontWeight;
copy.UseDefaultFont = a.UseDefaultFont;
+ copy.EditorTextColor = a.EditorTextColor;
Columns.Add(copy);
ActiveColumnId = copy.Id;
diff --git a/src/ColumnPadStudio/ViewModels/MainViewModel.FileState.cs b/src/ColumnPadStudio/ViewModels/MainViewModel.FileState.cs
index c99fdcb..593815e 100644
--- a/src/ColumnPadStudio/ViewModels/MainViewModel.FileState.cs
+++ b/src/ColumnPadStudio/ViewModels/MainViewModel.FileState.cs
@@ -40,49 +40,19 @@ private string CaptureDirtyState()
{
return CurrentFileKind switch
{
- SaveFileKind.TextDocument or SaveFileKind.MarkdownDocument => BuildSingleDocumentText(),
+ SaveFileKind.TextDocument => BuildSingleDocumentText(),
SaveFileKind.TextExport => BuildExportText(),
- SaveFileKind.MarkdownExport => BuildExportMarkdown(),
- _ => JsonSerializer.Serialize(new DirtyWorkspaceState(
- ShowLineNumbers,
- WordWrap,
- EditorFontFamily,
- EditorFontStyleName,
- EditorFontSize,
- ThemePreset,
- SpellCheckEnabled,
- EditorLanguageTag,
- LinedPaperEnabled,
- Columns.Select(c => new DirtyColumnState(
- c.Title,
- c.Text ?? string.Empty,
- c.WidthPx,
- c.IsWidthLocked,
- c.PastePreset.ToString(),
- c.LineMarkerMode.ToString(),
- c.GetCheckedChecklistLineIndexes().ToList(),
- c.Images.Select(image => new LayoutImage(
- image.FilePath,
- image.OriginalFileName,
- image.Width,
- image.PixelWidth,
- image.PixelHeight,
- image.Left,
- image.Top,
- image.Layer.ToString())).ToList(),
- c.EditorFontFamily,
- c.EditorFontSize,
- c.EditorFontStyle.ToString(),
- c.EditorFontWeight.ToString(),
- c.UseDefaultFont)).ToList()))
+ SaveFileKind.JsonExport => BuildExportJson(),
+ _ => JsonSerializer.Serialize(CreateLayoutSnapshot(includeActiveSelection: false, includeImageContent: false))
};
}
- private bool IsRawDocumentKind => CurrentFileKind is SaveFileKind.TextDocument or SaveFileKind.MarkdownDocument;
+ private bool IsRawDocumentKind => CurrentFileKind == SaveFileKind.TextDocument;
+ private bool IsLossyDocumentKind => IsRawDocumentKind || CurrentFileKind is SaveFileKind.TextExport or SaveFileKind.JsonExport;
public void PrepareForRichContent()
{
- if (IsRawDocumentKind)
+ if (IsLossyDocumentKind)
SetCurrentFileReference(null, SaveFileKind.Layout);
ForceDirty();
diff --git a/src/ColumnPadStudio/ViewModels/MainViewModel.Fonts.cs b/src/ColumnPadStudio/ViewModels/MainViewModel.Fonts.cs
index 1613588..60c8453 100644
--- a/src/ColumnPadStudio/ViewModels/MainViewModel.Fonts.cs
+++ b/src/ColumnPadStudio/ViewModels/MainViewModel.Fonts.cs
@@ -132,6 +132,22 @@ private static List BuildFontFaceOptions(FontFamily family)
.ToList();
}
+ private static FontFaceOption ResolveFontFaceOption(string familyName, string? preferredStyleName)
+ {
+ var family = Fonts.SystemFontFamilies.FirstOrDefault(candidate =>
+ string.Equals(candidate.Source, familyName, StringComparison.OrdinalIgnoreCase));
+
+ family ??= new FontFamily(familyName);
+ var options = BuildFontFaceOptions(family);
+ foreach (var option in options)
+ {
+ if (string.Equals(option.Name, preferredStyleName, StringComparison.OrdinalIgnoreCase))
+ return option;
+ }
+
+ return options[0];
+ }
+
private static string ToStyleName(FontStyle style, FontWeight weight)
{
var parts = new List();
diff --git a/src/ColumnPadStudio/ViewModels/MainViewModel.JsonHelpers.cs b/src/ColumnPadStudio/ViewModels/MainViewModel.JsonHelpers.cs
index 4d0231c..4a3ac27 100644
--- a/src/ColumnPadStudio/ViewModels/MainViewModel.JsonHelpers.cs
+++ b/src/ColumnPadStudio/ViewModels/MainViewModel.JsonHelpers.cs
@@ -2,6 +2,7 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
+using ColumnPadStudio.Services;
namespace ColumnPadStudio.ViewModels;
@@ -118,13 +119,14 @@ private static List ReadLayoutImages(JsonObject? node)
continue;
var filePath = GetJsonValueOrDefault(imageNode, nameof(LayoutImage.FilePath), string.Empty);
- if (string.IsNullOrWhiteSpace(filePath))
+ var content = ReadEmbeddedImageContent(imageNode);
+ if (string.IsNullOrWhiteSpace(filePath) && content is null)
continue;
var originalFileName = GetJsonValueOrDefault(
imageNode,
nameof(LayoutImage.OriginalFileName),
- Path.GetFileName(filePath));
+ string.IsNullOrWhiteSpace(filePath) ? "Picture" : Path.GetFileName(filePath));
var width = GetJsonDoubleOrDefault(imageNode, nameof(LayoutImage.Width), 320.0);
var pixelWidth = GetJsonValueOrDefault(imageNode, nameof(LayoutImage.PixelWidth), 0);
var pixelHeight = GetJsonValueOrDefault(imageNode, nameof(LayoutImage.PixelHeight), 0);
@@ -135,14 +137,41 @@ private static List ReadLayoutImages(JsonObject? node)
nameof(LayoutImage.Layer),
nameof(ColumnImageLayer.InFrontOfText));
- parsed.Add(new LayoutImage(filePath, originalFileName, width, pixelWidth, pixelHeight, left, top, layer));
+ parsed.Add(new LayoutImage(filePath, originalFileName, width, pixelWidth, pixelHeight, left, top, layer, content));
}
return parsed;
}
+ private static byte[]? ReadEmbeddedImageContent(JsonObject imageNode)
+ {
+ if (imageNode[nameof(LayoutImage.Content)] is not JsonValue contentNode)
+ return null;
+
+ if (!contentNode.TryGetValue(out var encodedContent) || string.IsNullOrWhiteSpace(encodedContent))
+ return null;
+
+ var maximumEncodedLength = ((ColumnImageFileService.MaxImageFileBytes + 2L) / 3L * 4L) + 4L;
+ if (encodedContent.Length > maximumEncodedLength)
+ throw new InvalidDataException("Embedded picture data is too large.");
+
+ try
+ {
+ var content = Convert.FromBase64String(encodedContent);
+ if (content.Length == 0 || content.Length > ColumnImageFileService.MaxImageFileBytes)
+ throw new InvalidDataException("Embedded picture data is empty or too large.");
+
+ return content;
+ }
+ catch (FormatException ex)
+ {
+ throw new InvalidDataException("Embedded picture data is not valid Base64.", ex);
+ }
+ }
+
private static ColumnImageLayer ParseImageLayer(string? value)
=> Enum.TryParse(value, ignoreCase: true, out var parsed)
+ && Enum.IsDefined(parsed)
? parsed
: ColumnImageLayer.InFrontOfText;
}
diff --git a/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutMigration.cs b/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutMigration.cs
index 500ea17..1d7fc07 100644
--- a/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutMigration.cs
+++ b/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutMigration.cs
@@ -1,13 +1,15 @@
-using System.Text;
using ColumnPadStudio.Domain.Lists;
namespace ColumnPadStudio.ViewModels;
public sealed partial class MainViewModel
{
+ private const int StructuredTextLayoutVersion = 14;
+
private static PasteListPreset ParsePastePreset(string? value)
{
- if (Enum.TryParse(value, ignoreCase: true, out var parsed))
+ if (Enum.TryParse(value, ignoreCase: true, out var parsed)
+ && Enum.IsDefined(parsed))
return parsed;
return PasteListPreset.None;
@@ -15,7 +17,8 @@ private static PasteListPreset ParsePastePreset(string? value)
private static LineMarkerMode ParseLineMarkerMode(string? value)
{
- if (Enum.TryParse(value, ignoreCase: true, out var parsed))
+ if (Enum.TryParse(value, ignoreCase: true, out var parsed)
+ && Enum.IsDefined(parsed))
return parsed;
return LineMarkerMode.Numbers;
@@ -43,7 +46,7 @@ private static (string Text, LineMarkerMode Mode, List CheckedChecklistLine
IReadOnlyList? persistedCheckedChecklistLineIndexes)
{
var normalizedIndexes = NormalizeCheckedChecklistLineIndexes(persistedCheckedChecklistLineIndexes);
- if (layoutVersion >= CurrentLayoutVersion)
+ if (layoutVersion >= StructuredTextLayoutVersion)
return (text, persistedMode, normalizedIndexes);
if (string.IsNullOrWhiteSpace(text))
@@ -107,7 +110,7 @@ private static (string Text, LineMarkerMode Mode, List CheckedChecklistLine
return (text, persistedMode, normalizedIndexes);
}
- private static string NormalizeLoadedColumnText(string? text)
+ private static string NormalizeLoadedColumnText(int layoutVersion, string? text)
{
if (string.IsNullOrEmpty(text))
return string.Empty;
@@ -115,8 +118,11 @@ private static string NormalizeLoadedColumnText(string? text)
var normalized = text;
var hasNewLine = normalized.Contains('\n') || normalized.Contains('\r');
- // Legacy files may contain escaped newline text sequences instead of real newlines.
- if (!hasNewLine)
+ // A short-lived legacy writer escaped every newline. Require both the old
+ // schema and its CRLF signature so ordinary code containing "\\n" is untouched.
+ if (layoutVersion < StructuredTextLayoutVersion &&
+ !hasNewLine &&
+ normalized.Contains("\\r\\n", StringComparison.Ordinal))
{
normalized = normalized
.Replace("\\r\\n", "\n", StringComparison.Ordinal)
@@ -128,93 +134,4 @@ private static string NormalizeLoadedColumnText(string? text)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace("\r", "\n", StringComparison.Ordinal);
}
-
- private static string MigrateLegacyInlineTextIfNeeded(int layoutVersion, string text, int? widthPx, double fontSize)
- {
- if (layoutVersion >= CurrentLayoutVersion)
- return text;
-
- if (string.IsNullOrWhiteSpace(text))
- return text;
-
- if (text.Contains('\n') || text.Contains('\r'))
- return text;
-
- if (text.Length < 80)
- return text;
-
- if (TrySplitArrowChain(text, out var structured))
- return structured;
-
- return HardWrapAtEstimatedWidth(text, EstimateCharactersPerLine(widthPx, fontSize));
- }
-
- private static bool TrySplitArrowChain(string text, out string normalized)
- {
- normalized = text;
- if (!text.Contains("->", StringComparison.Ordinal))
- return false;
-
- var segments = text.Split("->", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
- if (segments.Length < 3)
- return false;
-
- var lines = new List(segments.Length);
- for (var i = 0; i < segments.Length; i++)
- {
- var line = segments[i];
- if (i < segments.Length - 1)
- line += " ->";
-
- lines.Add(line);
- }
-
- normalized = string.Join('\n', lines);
- return true;
- }
-
- private static int EstimateCharactersPerLine(int? widthPx, double fontSize)
- {
- var effectiveWidth = Math.Max(180, widthPx ?? 320) - 72;
- var averageGlyphWidth = Math.Max(6.2, fontSize * 0.58);
- var estimated = (int)Math.Floor(effectiveWidth / averageGlyphWidth);
- return Math.Clamp(estimated, 18, 72);
- }
-
- private static string HardWrapAtEstimatedWidth(string text, int maxCharsPerLine)
- {
- if (string.IsNullOrWhiteSpace(text))
- return text;
-
- var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
- if (words.Length <= 1)
- return text;
-
- var lines = new List();
- var current = new StringBuilder();
-
- foreach (var word in words)
- {
- if (current.Length == 0)
- {
- current.Append(word);
- continue;
- }
-
- if (current.Length + 1 + word.Length <= maxCharsPerLine)
- {
- current.Append(' ').Append(word);
- continue;
- }
-
- lines.Add(current.ToString());
- current.Clear();
- current.Append(word);
- }
-
- if (current.Length > 0)
- lines.Add(current.ToString());
-
- return lines.Count <= 1 ? text : string.Join('\n', lines);
- }
}
diff --git a/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutPersistence.cs b/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutPersistence.cs
index 6810f4c..7370a8e 100644
--- a/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutPersistence.cs
+++ b/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutPersistence.cs
@@ -1,6 +1,8 @@
+using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using ColumnPadStudio.Domain.Lists;
+using ColumnPadStudio.Domain.Workspaces;
using ColumnPadStudio.Models;
using ColumnPadStudio.Services;
@@ -10,9 +12,27 @@ public sealed partial class MainViewModel
{
public string ToLayoutJson()
{
- var layout = new LayoutFile(
+ return SerializeLayoutSnapshot(CaptureRecoveryLayoutSnapshot());
+ }
+
+ internal LayoutFile CaptureRecoveryLayoutSnapshot()
+ {
+ return CreateLayoutSnapshot(includeActiveSelection: true, includeImageContent: true);
+ }
+
+ internal static string SerializeLayoutSnapshot(LayoutFile snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ return JsonSerializer.Serialize(snapshot, LayoutJsonOptions);
+ }
+
+ private LayoutFile CreateLayoutSnapshot(bool includeActiveSelection, bool includeImageContent)
+ {
+ return new LayoutFile(
+ FileType: LayoutFileType,
Version: CurrentLayoutVersion,
ShowLineNumbers: ShowLineNumbers,
+ GutterWidthPx: GutterWidthPx,
WordWrap: WordWrap,
EditorFontFamily: EditorFontFamily,
EditorFontStyle: EditorFontStyleName,
@@ -21,17 +41,27 @@ public string ToLayoutJson()
SpellCheckEnabled: SpellCheckEnabled,
EditorLanguageTag: EditorLanguageTag,
LinedPaperEnabled: LinedPaperEnabled,
- ActiveId: ActiveColumnId,
- ActiveIndex: GetActiveColumnIndex(),
- Columns: Columns.Select(column => new LayoutColumn(
- column.Title,
- column.Text ?? string.Empty,
- column.WidthPx,
- column.IsWidthLocked,
- column.PastePreset.ToString(),
- column.LineMarkerMode.ToString(),
- column.GetCheckedChecklistLineIndexes().ToList(),
- column.Images.Select(image => new LayoutImage(
+ PaperStyle: SelectedPaperStyle.ToString(),
+ ActiveId: includeActiveSelection ? ActiveColumnId : null,
+ ActiveIndex: includeActiveSelection ? GetActiveColumnIndex() : null,
+ Columns: Columns
+ .Select(column => CreateLayoutColumnSnapshot(column, includeImageContent))
+ .ToList()
+ .AsReadOnly());
+ }
+
+ private static LayoutColumn CreateLayoutColumnSnapshot(ColumnViewModel column, bool includeImageContent)
+ {
+ return new LayoutColumn(
+ column.Title,
+ column.Text ?? string.Empty,
+ column.WidthPx,
+ column.IsWidthLocked,
+ column.PastePreset.ToString(),
+ column.LineMarkerMode.ToString(),
+ column.GetCheckedChecklistLineIndexes().ToList().AsReadOnly(),
+ column.Images
+ .Select(image => new LayoutImage(
image.FilePath,
image.OriginalFileName,
image.Width,
@@ -39,14 +69,18 @@ public string ToLayoutJson()
image.PixelHeight,
image.Left,
image.Top,
- image.Layer.ToString())).ToList(),
- column.EditorFontFamily,
- column.EditorFontSize,
- column.EditorFontStyle.ToString(),
- column.EditorFontWeight.ToString(),
- column.UseDefaultFont)).ToList());
-
- return JsonSerializer.Serialize(layout, LayoutJsonOptions);
+ image.Layer.ToString(),
+ includeImageContent && image.ImageContent is not null
+ ? (byte[])image.ImageContent.Clone()
+ : null))
+ .ToList()
+ .AsReadOnly(),
+ column.EditorFontFamily,
+ column.EditorFontSize,
+ column.EditorFontStyle.ToString(),
+ column.EditorFontWeight.ToString(),
+ column.UseDefaultFont,
+ column.EditorTextColor);
}
public bool LoadFromJson(
@@ -73,10 +107,27 @@ public bool LoadFromJson(
var currentTheme = ThemePreset;
var layoutVersion = GetJsonValueOrDefault(root, nameof(LayoutFile.Version), 0);
+ string? fileType = null;
+ var hasFileType = root[nameof(LayoutFile.FileType)] is JsonValue fileTypeNode &&
+ fileTypeNode.TryGetValue(out fileType);
+ if (root.ContainsKey(nameof(LayoutFile.FileType)) && !hasFileType)
+ return RejectInvalidLayout("Invalid layout file type.");
+
+ if (hasFileType && !string.Equals(fileType, LayoutFileType, StringComparison.Ordinal))
+ return RejectInvalidLayout("This file is not a ColumnPad layout.");
+
+ if (layoutVersion < 0 || layoutVersion > CurrentLayoutVersion)
+ return RejectInvalidLayout("This layout was created by a newer version of ColumnPad.");
+
+ if (layoutVersion >= CurrentLayoutVersion && !hasFileType)
+ return RejectInvalidLayout("Invalid ColumnPad layout header.");
+
var showLineNumbers = GetJsonValueOrDefault(root, nameof(LayoutFile.ShowLineNumbers), true);
+ var gutterWidthPx = GetJsonValueOrDefault(root, nameof(LayoutFile.GutterWidthPx), DefaultGutterWidthPx);
var wordWrap = GetJsonValueOrDefault(root, nameof(LayoutFile.WordWrap), true);
var fontFamily = GetJsonValueOrDefault(root, nameof(LayoutFile.EditorFontFamily), "Consolas");
var fontStyle = GetJsonValueOrDefault(root, nameof(LayoutFile.EditorFontStyle), "Regular");
+ var defaultColumnFontFace = ResolveFontFaceOption(ResolveInstalledFamily(fontFamily), fontStyle);
var theme = preserveCurrentTheme
? currentTheme
: GetJsonValueOrDefault(root, nameof(LayoutFile.ThemePreset), ThemePresets[0]);
@@ -86,14 +137,30 @@ public bool LoadFromJson(
var editorLanguageTag = NormalizeEditorLanguageTag(
GetJsonValueOrDefault(root, nameof(LayoutFile.EditorLanguageTag), defaultLanguageTag));
var linedPaperEnabled = GetJsonValueOrDefault(root, nameof(LayoutFile.LinedPaperEnabled), false);
+ var paperStyle = ParsePaperStyle(
+ GetJsonValueOrDefault(root, nameof(LayoutFile.PaperStyle), PaperStyle.Ruled.ToString()));
if (root[nameof(LayoutFile.Columns)] is not JsonArray columnNodes || columnNodes.Count == 0)
return RejectInvalidLayout();
+ if (columnNodes.Count > WorkspaceConstraints.MaxColumns)
+ {
+ return RejectInvalidLayout(
+ $"This layout contains {columnNodes.Count} columns. ColumnPad supports up to {WorkspaceConstraints.MaxColumns} columns.");
+ }
+
var parsedColumns = new List(columnNodes.Count);
for (var index = 0; index < columnNodes.Count; index++)
{
- if (!TryParseLayoutColumn(columnNodes[index], index, layoutVersion, fontFamily, fontSize, out var column))
+ if (!TryParseLayoutColumn(
+ columnNodes[index],
+ index,
+ layoutVersion,
+ fontFamily,
+ fontSize,
+ defaultColumnFontFace.Style.ToString(),
+ defaultColumnFontFace.Weight.ToString(),
+ out var column))
return false;
parsedColumns.Add(column);
@@ -101,6 +168,7 @@ public bool LoadFromJson(
Columns.Clear();
ShowLineNumbers = showLineNumbers;
+ GutterWidthPx = gutterWidthPx;
WordWrap = wordWrap;
EditorFontFamily = fontFamily;
EditorFontStyleName = fontStyle;
@@ -108,6 +176,7 @@ public bool LoadFromJson(
ThemePreset = theme;
SpellCheckEnabled = spellCheckEnabled;
EditorLanguageTag = editorLanguageTag;
+ SelectedPaperStyle = paperStyle;
LinedPaperEnabled = linedPaperEnabled;
foreach (var column in parsedColumns)
@@ -122,6 +191,20 @@ public bool LoadFromJson(
return true;
}
+ private static PaperStyle ParsePaperStyle(string? value)
+ {
+ if (string.Equals(value, "Grid", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(value, "Dots", StringComparison.OrdinalIgnoreCase))
+ {
+ return PaperStyle.Ruled;
+ }
+
+ return Enum.TryParse(value, ignoreCase: true, out var parsed)
+ && Enum.IsDefined(parsed)
+ ? parsed
+ : PaperStyle.Ruled;
+ }
+
public bool LoadRecoverySnapshot(WorkspaceRecoveryWorkspace workspace, bool preserveCurrentTheme = false)
{
ArgumentNullException.ThrowIfNull(workspace);
@@ -149,6 +232,8 @@ private bool TryParseLayoutColumn(
int layoutVersion,
string defaultFontFamily,
double defaultFontSize,
+ string defaultFontStyle,
+ string defaultFontWeight,
out LayoutColumn column)
{
column = default!;
@@ -170,10 +255,19 @@ private bool TryParseLayoutColumn(
var markerMode = ParseLineMarkerMode(
GetJsonValueOrDefault(source, nameof(LayoutColumn.LineMarkerMode), nameof(LineMarkerMode.Numbers)));
var checkedRows = GetJsonIntArray(source, nameof(LayoutColumn.CheckedChecklistLineIndexes));
- var text = NormalizeLoadedColumnText(GetJsonValueOrDefault(source, nameof(LayoutColumn.Text), string.Empty));
+ var text = NormalizeLoadedColumnText(layoutVersion, GetJsonValueOrDefault(source, nameof(LayoutColumn.Text), string.Empty));
var columnFontSize = GetJsonDoubleOrDefault(source, nameof(LayoutColumn.FontSize), defaultFontSize);
+ List images;
+ try
+ {
+ images = ReadLayoutImages(source);
+ }
+ catch (InvalidDataException)
+ {
+ StatusText = $"Invalid layout file: Column {displayIndex} contains damaged or oversized picture data.";
+ return false;
+ }
- text = MigrateLegacyInlineTextIfNeeded(layoutVersion, text, width, columnFontSize);
var markerMigration = MigrateLegacyLineMarkersIfNeeded(layoutVersion, text, markerMode, checkedRows);
column = new LayoutColumn(
@@ -184,12 +278,14 @@ private bool TryParseLayoutColumn(
ParsePastePreset(GetJsonValueOrDefault(source, nameof(LayoutColumn.PastePreset), nameof(PasteListPreset.None))).ToString(),
markerMigration.Mode.ToString(),
markerMigration.CheckedChecklistLineIndexes,
- ReadLayoutImages(source),
+ images,
GetJsonValueOrDefault(source, nameof(LayoutColumn.FontFamily), defaultFontFamily),
columnFontSize,
- GetJsonValueOrDefault(source, nameof(LayoutColumn.FontStyle), _editorFontStyle.ToString()),
- GetJsonValueOrDefault(source, nameof(LayoutColumn.FontWeight), _editorFontWeight.ToString()),
- GetJsonValueOrDefault(source, nameof(LayoutColumn.UseDefaultFont), true));
+ GetJsonValueOrDefault(source, nameof(LayoutColumn.FontStyle), defaultFontStyle),
+ GetJsonValueOrDefault(source, nameof(LayoutColumn.FontWeight), defaultFontWeight),
+ GetJsonValueOrDefault(source, nameof(LayoutColumn.UseDefaultFont), true),
+ ColumnTextColorService.Normalize(
+ GetJsonValueOrDefault(source, nameof(LayoutColumn.EditorTextColor), ColumnTextColorService.ThemeDefault)));
return true;
}
@@ -213,7 +309,8 @@ private ColumnViewModel CreateColumnFromLayout(LayoutColumn column)
image.PixelHeight,
image.Left,
image.Top,
- ParseImageLayer(image.Layer)));
+ ParseImageLayer(image.Layer),
+ image.Content));
}
viewModel.EditorFontFamily = string.IsNullOrWhiteSpace(column.FontFamily) ? EditorFontFamily : column.FontFamily;
@@ -221,6 +318,7 @@ private ColumnViewModel CreateColumnFromLayout(LayoutColumn column)
viewModel.EditorFontStyle = ParseFontStyle(column.FontStyle, _editorFontStyle);
viewModel.EditorFontWeight = ParseFontWeight(column.FontWeight, _editorFontWeight);
viewModel.UseDefaultFont = column.UseDefaultFont;
+ viewModel.EditorTextColor = column.EditorTextColor;
return viewModel;
}
@@ -239,9 +337,9 @@ private void RestoreActiveColumn(JsonObject root)
ActiveColumnId = Columns.First().Id;
}
- private bool RejectInvalidLayout()
+ private bool RejectInvalidLayout(string message = "Invalid layout file.")
{
- StatusText = "Invalid layout file.";
+ StatusText = message;
return false;
}
}
diff --git a/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutSchema.cs b/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutSchema.cs
index fb03b79..e1093d0 100644
--- a/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutSchema.cs
+++ b/src/ColumnPadStudio/ViewModels/MainViewModel.LayoutSchema.cs
@@ -2,9 +2,11 @@ namespace ColumnPadStudio.ViewModels;
public sealed partial class MainViewModel
{
- private sealed record LayoutFile(
+ internal sealed record LayoutFile(
+ string FileType,
int Version,
bool ShowLineNumbers,
+ int GutterWidthPx,
bool WordWrap,
string EditorFontFamily,
string EditorFontStyle,
@@ -13,26 +15,28 @@ private sealed record LayoutFile(
bool SpellCheckEnabled,
string EditorLanguageTag,
bool LinedPaperEnabled,
+ string PaperStyle,
string? ActiveId,
int? ActiveIndex,
- List Columns);
+ IReadOnlyList Columns);
- private sealed record LayoutColumn(
+ internal sealed record LayoutColumn(
string Title,
string Text,
int? WidthPx,
bool IsWidthLocked,
string PastePreset,
string LineMarkerMode,
- List CheckedChecklistLineIndexes,
- List Images,
+ IReadOnlyList CheckedChecklistLineIndexes,
+ IReadOnlyList Images,
string FontFamily,
double FontSize,
string FontStyle,
string FontWeight,
- bool UseDefaultFont);
+ bool UseDefaultFont,
+ string EditorTextColor);
- private sealed record LayoutImage(
+ internal sealed record LayoutImage(
string FilePath,
string OriginalFileName,
double Width,
@@ -40,32 +44,7 @@ private sealed record LayoutImage(
int PixelHeight,
double Left,
double Top,
- string Layer);
+ string Layer,
+ byte[]? Content);
- private sealed record DirtyWorkspaceState(
- bool ShowLineNumbers,
- bool WordWrap,
- string EditorFontFamily,
- string EditorFontStyle,
- double EditorFontSize,
- string ThemePreset,
- bool SpellCheckEnabled,
- string EditorLanguageTag,
- bool LinedPaperEnabled,
- List Columns);
-
- private sealed record DirtyColumnState(
- string Title,
- string Text,
- int? WidthPx,
- bool IsWidthLocked,
- string PastePreset,
- string LineMarkerMode,
- List CheckedChecklistLineIndexes,
- List Images,
- string FontFamily,
- double FontSize,
- string FontStyle,
- string FontWeight,
- bool UseDefaultFont);
}
diff --git a/src/ColumnPadStudio/ViewModels/MainViewModel.Persistence.cs b/src/ColumnPadStudio/ViewModels/MainViewModel.Persistence.cs
index cb9f60e..e8c8b87 100644
--- a/src/ColumnPadStudio/ViewModels/MainViewModel.Persistence.cs
+++ b/src/ColumnPadStudio/ViewModels/MainViewModel.Persistence.cs
@@ -21,14 +21,13 @@ public void SaveToPath(string path, SaveFileKind kind)
switch (kind)
{
case SaveFileKind.TextDocument:
- case SaveFileKind.MarkdownDocument:
AtomicFileWriter.WriteText(path, BuildSingleDocumentText(), Encoding.UTF8);
break;
case SaveFileKind.TextExport:
AtomicFileWriter.WriteText(path, BuildExportText(), Encoding.UTF8);
break;
- case SaveFileKind.MarkdownExport:
- AtomicFileWriter.WriteText(path, BuildExportMarkdown(), Encoding.UTF8);
+ case SaveFileKind.JsonExport:
+ AtomicFileWriter.WriteText(path, BuildExportJson(), Encoding.UTF8);
break;
default:
AtomicFileWriter.WriteText(path, ToLayoutJson(), Encoding.UTF8);
diff --git a/src/ColumnPadStudio/ViewModels/MainViewModel.TextDocuments.cs b/src/ColumnPadStudio/ViewModels/MainViewModel.TextDocuments.cs
index 785fbc6..d8fe302 100644
--- a/src/ColumnPadStudio/ViewModels/MainViewModel.TextDocuments.cs
+++ b/src/ColumnPadStudio/ViewModels/MainViewModel.TextDocuments.cs
@@ -1,7 +1,10 @@
using System.Text;
+using System.IO;
+using System.Text.Json;
using ColumnPadStudio.Domain.Lists;
using ColumnPadStudio.Domain.Workspaces;
using ColumnPadStudio.Models;
+using ColumnPadStudio.Services;
namespace ColumnPadStudio.ViewModels;
@@ -12,32 +15,35 @@ public string BuildExportText()
var builder = new StringBuilder();
builder.AppendLine(WorkspaceImportRules.TextExportMarker);
builder.AppendLine(WorkspaceImportRules.TextExportFormatLine);
+ builder.AppendLine(WorkspaceImportRules.TextExportVersionLine);
builder.AppendLine();
for (var i = 0; i < Columns.Count; i++)
{
var column = Columns[i];
var title = BuildExportTitle(column.Title, i);
- AppendExportSection(builder, $"===== {title} =====", column.Text, i < Columns.Count - 1);
+ AppendExportSection(
+ builder,
+ $"===== {title} =====",
+ WorkspaceImportRules.EscapeTextExportBody(column.Text),
+ i < Columns.Count - 1);
}
return builder.ToString();
}
- public string BuildExportMarkdown()
+ public string BuildExportJson()
{
- var builder = new StringBuilder();
- builder.AppendLine(WorkspaceImportRules.MarkdownExportMarker);
- builder.AppendLine();
-
- for (var i = 0; i < Columns.Count; i++)
- {
- var column = Columns[i];
- var title = BuildExportTitle(column.Title, i);
- AppendExportSection(builder, $"## {title}", column.Text, i < Columns.Count - 1);
- }
-
- return builder.ToString();
+ var export = new TextExportFile(
+ FileType: WorkspaceImportRules.JsonExportFileType,
+ Version: WorkspaceImportRules.CurrentJsonExportVersion,
+ Columns: Columns
+ .Select((column, index) => new TextExportColumn(
+ Title: BuildExportTitle(column.Title, index),
+ Text: column.Text ?? string.Empty))
+ .ToList());
+
+ return JsonSerializer.Serialize(export, LayoutJsonOptions);
}
public string BuildSingleDocumentText()
@@ -61,6 +67,7 @@ public void LoadTextDocument(
document.EditorFontStyle = _editorFontStyle;
document.EditorFontWeight = _editorFontWeight;
document.UseDefaultFont = true;
+ document.EditorTextColor = ColumnTextColorService.ThemeDefault;
Columns.Add(document);
ActiveColumnId = document.Id;
@@ -78,10 +85,10 @@ public void LoadFromExportText(string text, string? sourceLabel = null, string?
ApplyImportedColumns(parsed, sourceLabel, sourcePath, SaveFileKind.TextExport, "Text imported.");
}
- public void LoadFromExportMarkdown(string markdown, string? sourceLabel = null, string? sourcePath = null)
+ public void LoadFromExportJson(string json, string? sourceLabel = null, string? sourcePath = null)
{
- var parsed = WorkspaceImportRules.ParseMarkdownExportColumns(markdown);
- ApplyImportedColumns(parsed, sourceLabel, sourcePath, SaveFileKind.MarkdownExport, "Markdown imported.");
+ var parsed = WorkspaceImportRules.ParseJsonExportColumns(json);
+ ApplyImportedColumns(parsed, sourceLabel, sourcePath, SaveFileKind.JsonExport, "JSON imported.");
}
private static void AppendExportSection(StringBuilder builder, string header, string? body, bool appendSectionBreak)
@@ -121,6 +128,12 @@ private void ApplyImportedColumns(
SaveFileKind kind,
string fallbackStatus)
{
+ if (parsed.Count > WorkspaceConstraints.MaxColumns)
+ {
+ throw new InvalidDataException(
+ $"This file contains {parsed.Count} columns. ColumnPad supports up to {WorkspaceConstraints.MaxColumns} columns in one workspace.");
+ }
+
var imported = parsed.Count > 0
? parsed
: [new ImportedColumn("Column 1", string.Empty)];
@@ -145,6 +158,7 @@ private void ApplyImportedColumns(
column.EditorFontStyle = _editorFontStyle;
column.EditorFontWeight = _editorFontWeight;
column.UseDefaultFont = true;
+ column.EditorTextColor = ColumnTextColorService.ThemeDefault;
}
ActiveColumnId = Columns.First().Id;
@@ -154,4 +168,8 @@ private void ApplyImportedColumns(
StatusText = sourceLabel is null ? fallbackStatus : $"Opened: {sourceLabel}";
MarkClean();
}
+
+ private sealed record TextExportFile(string FileType, int Version, List Columns);
+
+ private sealed record TextExportColumn(string Title, string Text);
}
diff --git a/src/ColumnPadStudio/ViewModels/MainViewModel.cs b/src/ColumnPadStudio/ViewModels/MainViewModel.cs
index 1cebcac..a86cba6 100644
--- a/src/ColumnPadStudio/ViewModels/MainViewModel.cs
+++ b/src/ColumnPadStudio/ViewModels/MainViewModel.cs
@@ -10,12 +10,17 @@ namespace ColumnPadStudio.ViewModels;
public sealed partial class MainViewModel : NotifyBase
{
+ public const int MinimumGutterWidthPx = 32;
+ public const int MaximumGutterWidthPx = 160;
+ public const int DefaultGutterWidthPx = MinimumGutterWidthPx;
+
public event EventHandler? RequestRebuildColumns;
public ObservableCollection Columns { get; } = new();
private string? _activeColumnId;
private bool _showLineNumbers = true;
+ private int _gutterWidthPx = DefaultGutterWidthPx;
private bool _wordWrap = true;
private string _editorFontFamily = "Consolas";
private string _editorFontStyleName = "Regular";
@@ -26,12 +31,14 @@ public sealed partial class MainViewModel : NotifyBase
private bool _spellCheckEnabled = true;
private string _editorLanguageTag = "en-US";
private bool _linedPaperEnabled;
+ private PaperStyle _selectedPaperStyle = PaperStyle.Ruled;
private bool _requiresSaveAsBeforeOverwrite;
private string _statusText = "";
private string _cleanStateSignature = string.Empty;
private bool _forceDirty;
private static readonly JsonSerializerOptions LayoutJsonOptions = new() { WriteIndented = true };
- private const int CurrentLayoutVersion = 14;
+ private const string LayoutFileType = "ColumnPadLayout";
+ private const int CurrentLayoutVersion = 19;
private readonly Dictionary _fontFaceOptionsByName =
new(StringComparer.CurrentCultureIgnoreCase);
@@ -80,20 +87,53 @@ public string? ActiveColumnId
public bool IsLightThemeSelected => string.Equals(ThemePreset, ThemePresetService.LightPreset, StringComparison.Ordinal);
public bool IsDarkThemeSelected => string.Equals(ThemePreset, ThemePresetService.DarkPreset, StringComparison.Ordinal);
public string EditorFontSummary => $"{EditorFontFamily} {EditorFontStyleName} {EditorFontSize:0}";
+ public string GutterWidthMenuHeader => $"Gutter Width... ({GutterWidthPx} px)";
public string ProofingLanguageDisplayName => EditorLanguages.FirstOrDefault(language => string.Equals(language.Tag, EditorLanguageTag, StringComparison.OrdinalIgnoreCase))?.DisplayName ?? EditorLanguageTag;
public string ProofingLanguageHelpText => $"Proofing language: {ProofingLanguageDisplayName}. Spell-check availability depends on installed Windows/WPF dictionaries.";
+ public bool IsPaperOffSelected => !LinedPaperEnabled;
+ public bool IsRuledPaperSelected => LinedPaperEnabled && SelectedPaperStyle == PaperStyle.Ruled;
+ public bool IsSoftRuledPaperSelected => LinedPaperEnabled && SelectedPaperStyle == PaperStyle.SoftRuled;
+ public bool IsStrongRuledPaperSelected => LinedPaperEnabled && SelectedPaperStyle == PaperStyle.StrongRuled;
+ public string PaperStyleHelpText => LinedPaperEnabled
+ ? $"Paper style: {GetPaperStyleDisplayName(SelectedPaperStyle)}. Lines follow the text row spacing."
+ : $"Paper is off. The selected style is {GetPaperStyleDisplayName(SelectedPaperStyle)}.";
public bool ShowLineNumbers
{
get => _showLineNumbers;
set
{
- Set(ref _showLineNumbers, value);
+ if (_showLineNumbers == value)
+ return;
+
+ _showLineNumbers = value;
+ OnPropertyChanged();
foreach (var c in Columns) c.ShowLineNumbers = value;
RefreshStatus();
}
}
+ public int GutterWidthPx
+ {
+ get => _gutterWidthPx;
+ set
+ {
+ var normalized = Math.Clamp(value, MinimumGutterWidthPx, MaximumGutterWidthPx);
+ if (_gutterWidthPx == normalized)
+ return;
+
+ // A gutter width is native layout data. Do not allow saving it back to a
+ // plain-text/export source where the setting would be silently lost.
+ PrepareForRichContent();
+ _gutterWidthPx = normalized;
+ ApplyGutterWidthToColumns();
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(GutterWidthMenuHeader));
+ RefreshStatus();
+ StatusText = $"Set gutter width to {GutterWidthPx}px for this workspace.";
+ }
+ }
+
public bool WordWrap
{
get => _wordWrap;
@@ -200,11 +240,38 @@ public bool LinedPaperEnabled
get => _linedPaperEnabled;
set
{
- Set(ref _linedPaperEnabled, value);
+ if (_linedPaperEnabled == value)
+ return;
+
+ _linedPaperEnabled = value;
+ OnPropertyChanged();
+ NotifyPaperSelectionPropertiesChanged();
+ RefreshStatus();
+ }
+ }
+
+ public PaperStyle SelectedPaperStyle
+ {
+ get => _selectedPaperStyle;
+ set
+ {
+ var normalized = Enum.IsDefined(value) ? value : PaperStyle.Ruled;
+ if (_selectedPaperStyle == normalized)
+ return;
+
+ _selectedPaperStyle = normalized;
+ OnPropertyChanged();
+ NotifyPaperSelectionPropertiesChanged();
RefreshStatus();
}
}
+ public void UsePaperStyle(PaperStyle style)
+ {
+ SelectedPaperStyle = style;
+ LinedPaperEnabled = true;
+ }
+
public int ColumnCount
{
get => Columns.Count;
@@ -251,16 +318,27 @@ private ColumnViewModel MakeColumn(string title)
EditorFontSize = EditorFontSize,
EditorFontStyle = _editorFontStyle,
EditorFontWeight = _editorFontWeight,
- UseDefaultFont = true
+ UseDefaultFont = true,
+ EditorTextColor = ColumnTextColorService.ThemeDefault
};
+ c.SetSharedGutterWidth(GutterWidthPx);
return c;
}
+ private void ApplyGutterWidthToColumns()
+ {
+ foreach (var column in Columns)
+ column.SetSharedGutterWidth(GutterWidthPx);
+ }
+
private void Column_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is not ColumnViewModel column)
return;
+ if (IsLossyDocumentKind && RequiresNativeLayout(e.PropertyName))
+ SetCurrentFileReference(null, SaveFileKind.Layout);
+
if (!ReferenceEquals(column, GetActive()))
return;
@@ -268,6 +346,23 @@ private void Column_PropertyChanged(object? sender, PropertyChangedEventArgs e)
RefreshStatus();
}
+ private static bool RequiresNativeLayout(string? propertyName)
+ {
+ return propertyName is nameof(ColumnViewModel.Title)
+ or nameof(ColumnViewModel.WidthPx)
+ or nameof(ColumnViewModel.IsWidthLocked)
+ or nameof(ColumnViewModel.PastePreset)
+ or nameof(ColumnViewModel.LineMarkerMode)
+ or nameof(ColumnViewModel.GutterStateVersion)
+ or nameof(ColumnViewModel.Images)
+ or nameof(ColumnViewModel.EditorFontFamily)
+ or nameof(ColumnViewModel.EditorFontSize)
+ or nameof(ColumnViewModel.EditorFontStyle)
+ or nameof(ColumnViewModel.EditorFontWeight)
+ or nameof(ColumnViewModel.UseDefaultFont)
+ or nameof(ColumnViewModel.EditorTextColor);
+ }
+
private void ApplyEditorFontToColumns()
{
foreach (var c in Columns)
@@ -294,6 +389,15 @@ private void NotifyEditorFontSummaryChanged()
OnPropertyChanged(nameof(EditorFontSummary));
}
+ private void NotifyPaperSelectionPropertiesChanged()
+ {
+ OnPropertyChanged(nameof(IsPaperOffSelected));
+ OnPropertyChanged(nameof(IsRuledPaperSelected));
+ OnPropertyChanged(nameof(IsSoftRuledPaperSelected));
+ OnPropertyChanged(nameof(IsStrongRuledPaperSelected));
+ OnPropertyChanged(nameof(PaperStyleHelpText));
+ }
+
public void RefreshStatus()
{
var active = GetActive();
@@ -305,10 +409,18 @@ public void RefreshStatus()
: string.Empty;
var spellText = SpellCheckEnabled ? "On" : "Off";
- var paperText = LinedPaperEnabled ? "On" : "Off";
+ var paperText = LinedPaperEnabled ? GetPaperStyleDisplayName(SelectedPaperStyle) : "Off";
StatusText = $"Columns: {Columns.Count} Selected: {active?.Title ?? "-"} Line nums: {(ShowLineNumbers ? "On" : "Off")} Wrap: {(WordWrap ? "On" : "Off")} Font: {EditorFontFamily} {EditorFontStyleName} {EditorFontSize:0} Theme: {ThemePreset} Spell: {spellText} Proofing: {EditorLanguageTag} Paper: {paperText}{checkText}";
}
+ private static string GetPaperStyleDisplayName(PaperStyle style) => style switch
+ {
+ PaperStyle.Ruled => "Ruled",
+ PaperStyle.SoftRuled => "Soft ruled",
+ PaperStyle.StrongRuled => "Strong ruled",
+ _ => "Ruled"
+ };
+
public ColumnViewModel? GetActive()
{
if (ActiveColumnId is null) return Columns.FirstOrDefault();
diff --git a/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.ConnectionDraft.cs b/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.ConnectionDraft.cs
new file mode 100644
index 0000000..8544d7e
--- /dev/null
+++ b/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.ConnectionDraft.cs
@@ -0,0 +1,120 @@
+using ColumnPadStudio.Workflows;
+
+namespace ColumnPadStudio.ViewModels;
+
+public sealed partial class WorkflowBuilderViewModel
+{
+ private WorkflowDiagramNode? _connectionFromNode;
+ private WorkflowDiagramNode? _connectionToNode;
+ private string _connectionLabel = string.Empty;
+
+ public WorkflowDiagramNode? ConnectionFromNode
+ {
+ get => _connectionFromNode;
+ set
+ {
+ if (ReferenceEquals(_connectionFromNode, value))
+ return;
+
+ Set(ref _connectionFromNode, value);
+ OnPropertyChanged(nameof(CanCreateLink));
+ }
+ }
+
+ public WorkflowDiagramNode? ConnectionToNode
+ {
+ get => _connectionToNode;
+ set
+ {
+ if (ReferenceEquals(_connectionToNode, value))
+ return;
+
+ Set(ref _connectionToNode, value);
+ OnPropertyChanged(nameof(CanCreateLink));
+ }
+ }
+
+ public string ConnectionLabel
+ {
+ get => _connectionLabel;
+ set => Set(ref _connectionLabel, value ?? string.Empty);
+ }
+
+ private bool HasValidConnectionDraft()
+ {
+ var workflow = SelectedWorkflow;
+ var fromNode = ConnectionFromNode;
+ var toNode = ConnectionToNode;
+
+ if (workflow is null ||
+ fromNode is null ||
+ toNode is null ||
+ ReferenceEquals(fromNode, toNode) ||
+ !workflow.Nodes.Contains(fromNode) ||
+ !workflow.Nodes.Contains(toNode))
+ {
+ return false;
+ }
+
+ return !workflow.Links.Any(link =>
+ string.Equals(link.FromNodeId, fromNode.Id, StringComparison.Ordinal) &&
+ string.Equals(link.ToNodeId, toNode.Id, StringComparison.Ordinal));
+ }
+
+ private bool AddConnectionFromDraft()
+ {
+ if (!HasValidConnectionDraft() || SelectedWorkflow is null)
+ {
+ StatusText = "Choose two different nodes that are not already connected.";
+ return false;
+ }
+
+ var fromNode = ConnectionFromNode!;
+ var toNode = ConnectionToNode!;
+ var link = new WorkflowDiagramLink
+ {
+ FromNodeId = fromNode.Id,
+ ToNodeId = toNode.Id,
+ Label = ConnectionLabel.Trim()
+ };
+
+ SelectedWorkflow.Links.Add(link);
+ SelectedLink = link;
+ ConnectionFromNode = toNode;
+ ConnectionToNode = null;
+ ConnectionLabel = string.Empty;
+ StatusText = $"Connected {fromNode.Title} to {toNode.Title}.";
+ return true;
+ }
+
+ private void ResetConnectionDraft()
+ {
+ ConnectionFromNode = null;
+ ConnectionToNode = null;
+ ConnectionLabel = string.Empty;
+ }
+
+ private void UseSelectedNodeAsConnectionStart()
+ {
+ if (ConnectionToNode is null)
+ ConnectionFromNode = SelectedNode;
+ }
+
+ private void ValidateConnectionDraftNodes()
+ {
+ var workflow = SelectedWorkflow;
+ if (workflow is null)
+ {
+ ResetConnectionDraft();
+ return;
+ }
+
+ if (ConnectionFromNode is not null && !workflow.Nodes.Contains(ConnectionFromNode))
+ ConnectionFromNode = SelectedNode is not null && workflow.Nodes.Contains(SelectedNode) ? SelectedNode : workflow.Nodes.FirstOrDefault();
+
+ if (ConnectionToNode is not null && !workflow.Nodes.Contains(ConnectionToNode))
+ ConnectionToNode = null;
+
+ OnPropertyChanged(nameof(CanCreateLink));
+ }
+}
diff --git a/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.Library.cs b/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.Library.cs
index 424318d..b741b74 100644
--- a/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.Library.cs
+++ b/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.Library.cs
@@ -95,7 +95,7 @@ public bool ExportSelectedWorkflowToFile(string filePath)
return false;
_workflowService.ExportToPath(SelectedWorkflow, filePath);
- StatusText = $"Exported workflow JSON to {Path.GetFileName(filePath)}.";
+ StatusText = $"Exported reloadable workflow JSON to {Path.GetFileName(filePath)}.";
return true;
}
@@ -105,17 +105,7 @@ public bool ExportSelectedWorkflowTextToFile(string filePath)
return false;
_workflowService.ExportTextToPath(SelectedWorkflow, filePath);
- StatusText = $"Exported workflow text to {Path.GetFileName(filePath)}.";
- return true;
- }
-
- public bool ExportSelectedWorkflowMarkdownToFile(string filePath)
- {
- if (SelectedWorkflow is null || string.IsNullOrWhiteSpace(filePath))
- return false;
-
- _workflowService.ExportMarkdownToPath(SelectedWorkflow, filePath);
- StatusText = $"Exported workflow markdown to {Path.GetFileName(filePath)}.";
+ StatusText = $"Exported readable text copy to {Path.GetFileName(filePath)}.";
return true;
}
diff --git a/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.NodeActions.cs b/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.NodeActions.cs
index b6d4272..422d1a9 100644
--- a/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.NodeActions.cs
+++ b/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.NodeActions.cs
@@ -4,26 +4,33 @@ namespace ColumnPadStudio.ViewModels;
public sealed partial class WorkflowBuilderViewModel
{
+ private const double NodePlacementGap = 32;
+
public void AddNode(WorkflowNodeKind kind)
{
if (SelectedWorkflow is null)
return;
- var nodeIndex = SelectedWorkflow.Nodes.Count + 1;
var reference = SelectedNode;
var node = new WorkflowDiagramNode
{
- Id = $"node-{nodeIndex}",
+ Id = Guid.NewGuid().ToString("N"),
Kind = kind,
- Title = $"{WorkflowDiagramNode.DefaultTitleForKind(kind)} {nodeIndex}",
+ Title = CreateUniqueNodeTitle(
+ SelectedWorkflow,
+ WorkflowDiagramNode.DefaultTitleForKind(kind)),
Goal = DefaultGoalForKind(kind),
Instructions = DefaultInstructionsForKind(kind),
- ExpectedOutput = DefaultExpectedOutputForKind(kind),
- X = reference?.X ?? 320,
- Y = (reference?.Y ?? 120) + 110
+ ExpectedOutput = DefaultExpectedOutputForKind(kind)
};
+ (node.X, node.Y) = FindAvailableNodePosition(
+ SelectedWorkflow,
+ node.Width,
+ node.Height,
+ reference);
+
SelectedWorkflow.Nodes.Add(node);
SelectedNode = node;
OnPropertyChanged(nameof(CanCreateLink));
@@ -39,7 +46,7 @@ public bool DuplicateSelectedNode()
{
Id = Guid.NewGuid().ToString("N"),
Kind = SelectedNode.Kind,
- Title = $"{SelectedNode.Title} Copy",
+ Title = CreateUniqueCopyTitle(SelectedWorkflow, SelectedNode.Title),
Description = SelectedNode.Description,
Goal = SelectedNode.Goal,
Instructions = SelectedNode.Instructions,
@@ -50,13 +57,17 @@ public bool DuplicateSelectedNode()
Text = item.Text,
IsDone = item.IsDone
})),
- X = SelectedNode.X + 36,
- Y = SelectedNode.Y + 36,
Width = SelectedNode.Width,
Height = SelectedNode.Height,
Color = SelectedNode.Color
};
+ (clone.X, clone.Y) = FindAvailableNodePosition(
+ SelectedWorkflow,
+ clone.Width,
+ clone.Height,
+ SelectedNode);
+
SelectedWorkflow.Nodes.Add(clone);
SelectedNode = clone;
OnPropertyChanged(nameof(CanCreateLink));
@@ -128,35 +139,16 @@ public bool AutoLayoutSelectedWorkflow()
{
node.X = 80;
node.Y = y;
- y += 110;
+ y += node.Height + NodePlacementGap;
}
RefreshLinkPreviews();
- StatusText = "Auto-layout applied.";
+ StatusText = "Positions tidied.";
return true;
}
public bool AddLink()
- {
- if (SelectedWorkflow is null || SelectedWorkflow.Nodes.Count < 2)
- return false;
-
- var fromNode = SelectedNode ?? SelectedWorkflow.Nodes[0];
- var toNode = SelectedWorkflow.Nodes.FirstOrDefault(node => !string.Equals(node.Id, fromNode.Id, StringComparison.Ordinal))
- ?? SelectedWorkflow.Nodes[0];
-
- var link = new WorkflowDiagramLink
- {
- FromNodeId = fromNode.Id,
- ToNodeId = toNode.Id
- };
-
- SelectedWorkflow.Links.Add(link);
- SelectedLink = link;
- RefreshLinkPreviews();
- StatusText = "Connection added.";
- return true;
- }
+ => AddConnectionFromDraft();
public bool RemoveSelectedLink()
{
@@ -177,6 +169,82 @@ public bool RemoveSelectedLink()
return true;
}
+ private static string CreateUniqueNodeTitle(WorkflowDefinition workflow, string baseTitle)
+ {
+ var existingTitles = workflow.Nodes
+ .Select(node => node.Title)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ if (!existingTitles.Contains(baseTitle))
+ return baseTitle;
+
+ for (var suffix = 2; ; suffix++)
+ {
+ var candidate = $"{baseTitle} {suffix}";
+ if (!existingTitles.Contains(candidate))
+ return candidate;
+ }
+ }
+
+ private static string CreateUniqueCopyTitle(WorkflowDefinition workflow, string sourceTitle)
+ {
+ const string copyMarker = " Copy";
+ var baseTitle = sourceTitle;
+ var markerIndex = sourceTitle.LastIndexOf(copyMarker, StringComparison.OrdinalIgnoreCase);
+
+ if (markerIndex > 0)
+ {
+ var suffix = sourceTitle[(markerIndex + copyMarker.Length)..];
+ if (suffix.Length == 0 ||
+ (suffix.StartsWith(' ') && int.TryParse(suffix.AsSpan(1), out var copyNumber) && copyNumber >= 2))
+ {
+ baseTitle = sourceTitle[..markerIndex];
+ }
+ }
+
+ return CreateUniqueNodeTitle(workflow, $"{baseTitle}{copyMarker}");
+ }
+
+ private static (double X, double Y) FindAvailableNodePosition(
+ WorkflowDefinition workflow,
+ double width,
+ double height,
+ WorkflowDiagramNode? reference)
+ {
+ var x = Math.Max(0, reference?.X ?? 80);
+ var y = Math.Max(0, reference is null
+ ? 80
+ : reference.Y + reference.Height + NodePlacementGap);
+
+ while (true)
+ {
+ var nextY = y;
+ foreach (var node in workflow.Nodes)
+ {
+ if (!NodeAreasConflict(x, y, width, height, node))
+ continue;
+
+ nextY = Math.Max(nextY, node.Y + node.Height + NodePlacementGap);
+ }
+
+ if (nextY == y)
+ return (x, y);
+
+ y = nextY;
+ }
+ }
+
+ private static bool NodeAreasConflict(
+ double x,
+ double y,
+ double width,
+ double height,
+ WorkflowDiagramNode existing)
+ => x < existing.X + existing.Width + NodePlacementGap &&
+ x + width + NodePlacementGap > existing.X &&
+ y < existing.Y + existing.Height + NodePlacementGap &&
+ y + height + NodePlacementGap > existing.Y;
+
private static string DefaultGoalForKind(WorkflowNodeKind kind)
=> kind switch
{
diff --git a/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.Preview.cs b/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.Preview.cs
index ec65c4e..181db2b 100644
--- a/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.Preview.cs
+++ b/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.Preview.cs
@@ -90,6 +90,7 @@ private void WorkflowNodes_CollectionChanged(object? sender, NotifyCollectionCha
}
OnPropertyChanged(nameof(CanCreateLink));
+ ValidateConnectionDraftNodes();
NotifyDiagramCanvasSizeChanged();
RefreshLinkPreviews();
NotifyWorkflowDirtyStateChanged();
@@ -109,6 +110,7 @@ private void WorkflowLinks_CollectionChanged(object? sender, NotifyCollectionCha
item.PropertyChanged += WorkflowLink_PropertyChanged;
}
+ OnPropertyChanged(nameof(CanCreateLink));
RefreshLinkPreviews();
NotifyWorkflowDirtyStateChanged();
}
@@ -135,7 +137,10 @@ private void WorkflowLink_PropertyChanged(object? sender, PropertyChangedEventAr
NotifyWorkflowDirtyStateChanged();
if (e.PropertyName is nameof(WorkflowDiagramLink.FromNodeId) or nameof(WorkflowDiagramLink.ToNodeId) or nameof(WorkflowDiagramLink.Label))
+ {
+ OnPropertyChanged(nameof(CanCreateLink));
RefreshLinkPreviews();
+ }
}
private void Workflow_PropertyChanged(object? sender, PropertyChangedEventArgs e)
diff --git a/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.cs b/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.cs
index fb9264f..3d604ec 100644
--- a/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.cs
+++ b/src/ColumnPadStudio/ViewModels/WorkflowBuilderViewModel.cs
@@ -31,7 +31,6 @@ public sealed partial class WorkflowBuilderViewModel : NotifyBase
public ObservableCollection Templates { get; } = [];
public ObservableCollection LinkPreviews { get; } = [];
- public IReadOnlyList TriggerTypes { get; } = Enum.GetValues();
public IReadOnlyList NodeKinds { get; } = Enum.GetValues();
public IReadOnlyList NodeColors { get; } = Enum.GetValues();
@@ -52,6 +51,7 @@ public WorkflowDefinition? SelectedWorkflow
OnPropertyChanged(nameof(CanCreateLink));
OnPropertyChanged(nameof(SelectedWorkflowFileLabel));
+ ResetConnectionDraft();
SelectedNode = _selectedWorkflow?.Nodes.FirstOrDefault();
SelectedLink = _selectedWorkflow?.Links.FirstOrDefault();
RefreshLinkPreviews();
@@ -76,6 +76,7 @@ public WorkflowDiagramNode? SelectedNode
OnPropertyChanged();
OnPropertyChanged(nameof(HasSelectedNode));
+ UseSelectedNodeAsConnectionStart();
}
}
@@ -119,7 +120,7 @@ public WorkflowTemplateDefinition? SelectedTemplate
public bool HasSelectedNode => SelectedNode is not null;
public bool HasSelectedLink => SelectedLink is not null;
public bool HasSelectedTemplate => SelectedTemplate is not null;
- public bool CanCreateLink => SelectedWorkflow is { Nodes.Count: >= 2 };
+ public bool CanCreateLink => HasValidConnectionDraft();
public bool HasUnsavedChanges => Workflows.Any(IsWorkflowDirty);
public double DiagramCanvasWidth => CalculateDiagramCanvasWidth();
public double DiagramCanvasHeight => CalculateDiagramCanvasHeight();
diff --git a/src/ColumnPadStudio/ViewModels/WorkspaceSession.cs b/src/ColumnPadStudio/ViewModels/WorkspaceSession.cs
index 4eff0c7..751f043 100644
--- a/src/ColumnPadStudio/ViewModels/WorkspaceSession.cs
+++ b/src/ColumnPadStudio/ViewModels/WorkspaceSession.cs
@@ -6,17 +6,29 @@ public sealed class WorkspaceSession : NotifyBase
{
private string _name;
private bool _isRenaming;
+ private int _lastMultiColumnCount = 3;
+ private string _cleanMetadataSignature;
+ private bool _forceSessionDirty;
public WorkspaceSession(string name, MainViewModel vm)
{
_name = DisplayTextRules.CleanSingleLineLabel(name, "Workspace");
Vm = vm;
+ _cleanMetadataSignature = CaptureMetadataSignature();
}
public string Name
{
get => _name;
- set => Set(ref _name, DisplayTextRules.CleanSingleLineLabel(value, "Workspace"));
+ set
+ {
+ var normalized = DisplayTextRules.CleanSingleLineLabel(value, "Workspace");
+ if (string.Equals(_name, normalized, StringComparison.Ordinal))
+ return;
+
+ Set(ref _name, normalized);
+ NotifyDirtyStateChanged();
+ }
}
public bool IsRenaming
@@ -25,7 +37,43 @@ public bool IsRenaming
set => Set(ref _isRenaming, value);
}
- public int LastMultiColumnCount { get; set; } = 3;
+ public int LastMultiColumnCount
+ {
+ get => _lastMultiColumnCount;
+ set
+ {
+ var normalized = Math.Max(2, value);
+ if (_lastMultiColumnCount == normalized)
+ return;
+
+ Set(ref _lastMultiColumnCount, normalized);
+ NotifyDirtyStateChanged();
+ }
+ }
public MainViewModel Vm { get; }
+ public bool HasSessionChanges => _forceSessionDirty ||
+ !string.Equals(_cleanMetadataSignature, CaptureMetadataSignature(), StringComparison.Ordinal);
+ public bool IsDirty => Vm.IsDirty || HasSessionChanges;
+
+ public void MarkSessionClean()
+ {
+ _cleanMetadataSignature = CaptureMetadataSignature();
+ _forceSessionDirty = false;
+ NotifyDirtyStateChanged();
+ }
+
+ public void ForceSessionDirty()
+ {
+ _forceSessionDirty = true;
+ NotifyDirtyStateChanged();
+ }
+
+ private string CaptureMetadataSignature() => $"{Name}\0{LastMultiColumnCount}";
+
+ private void NotifyDirtyStateChanged()
+ {
+ OnPropertyChanged(nameof(HasSessionChanges));
+ OnPropertyChanged(nameof(IsDirty));
+ }
}
diff --git a/src/ColumnPadStudio/Workflows/WorkflowDefaults.cs b/src/ColumnPadStudio/Workflows/WorkflowDefaults.cs
index ea85f50..7c9b7a9 100644
--- a/src/ColumnPadStudio/Workflows/WorkflowDefaults.cs
+++ b/src/ColumnPadStudio/Workflows/WorkflowDefaults.cs
@@ -8,7 +8,6 @@ public static WorkflowDefinition CreateDefault(string? name = null)
{
Name = string.IsNullOrWhiteSpace(name) ? "New Workflow" : name.Trim(),
Category = "Custom",
- Trigger = WorkflowTriggerType.Manual,
Description = string.Empty
};
diff --git a/src/ColumnPadStudio/Workflows/WorkflowDefinition.cs b/src/ColumnPadStudio/Workflows/WorkflowDefinition.cs
index cf6c92e..685cc1f 100644
--- a/src/ColumnPadStudio/Workflows/WorkflowDefinition.cs
+++ b/src/ColumnPadStudio/Workflows/WorkflowDefinition.cs
@@ -8,13 +8,13 @@ namespace ColumnPadStudio.Workflows;
public sealed class WorkflowDefinition : NotifyBase
{
public const string WorkflowFileType = "ColumnPadWorkflow";
+ public const int CurrentSchemaVersion = 4;
- private int _schemaVersion = 3;
+ private int _schemaVersion = CurrentSchemaVersion;
private string _id = Guid.NewGuid().ToString("N");
private string _name = "New Workflow";
private string _category = "Custom";
private string _description = string.Empty;
- private WorkflowTriggerType _trigger = WorkflowTriggerType.Manual;
private ObservableCollection _nodes = [];
private ObservableCollection _links = [];
@@ -50,12 +50,6 @@ public string Description
set => Set(ref _description, value ?? string.Empty);
}
- public WorkflowTriggerType Trigger
- {
- get => _trigger;
- set => Set(ref _trigger, value);
- }
-
public ObservableCollection Nodes
{
get => _nodes;
diff --git a/src/ColumnPadStudio/Workflows/WorkflowEnums.cs b/src/ColumnPadStudio/Workflows/WorkflowEnums.cs
index 78f1163..8688d3d 100644
--- a/src/ColumnPadStudio/Workflows/WorkflowEnums.cs
+++ b/src/ColumnPadStudio/Workflows/WorkflowEnums.cs
@@ -1,13 +1,5 @@
namespace ColumnPadStudio.Workflows;
-public enum WorkflowTriggerType
-{
- Manual,
- OnAppStart,
- OnFileOpen,
- OnFileSave
-}
-
public enum WorkflowNodeKind
{
Start,
diff --git a/src/ColumnPadStudio/Workflows/WorkflowTemplateCatalog.Builders.cs b/src/ColumnPadStudio/Workflows/WorkflowTemplateCatalog.Builders.cs
index f1be9ed..039629f 100644
--- a/src/ColumnPadStudio/Workflows/WorkflowTemplateCatalog.Builders.cs
+++ b/src/ColumnPadStudio/Workflows/WorkflowTemplateCatalog.Builders.cs
@@ -15,7 +15,6 @@ private static WorkflowTemplateDefinition BuildLinearTemplate(
string name,
string category,
string description,
- WorkflowTriggerType trigger,
IReadOnlyList nodeTitles,
IReadOnlyDictionary? nodeDetails = null)
{
@@ -71,7 +70,6 @@ private static WorkflowTemplateDefinition BuildLinearTemplate(
Name = name,
Category = category,
Description = description,
- Trigger = trigger,
Nodes = nodes,
Connections = links
};
@@ -82,7 +80,6 @@ private static WorkflowTemplateDefinition BuildDecisionTemplate(
string name,
string category,
string description,
- WorkflowTriggerType trigger,
string startTitle,
string decisionTitle,
string yesTitle,
@@ -127,7 +124,6 @@ private static WorkflowTemplateDefinition BuildDecisionTemplate(
Name = name,
Category = category,
Description = description,
- Trigger = trigger,
Nodes =
[
new WorkflowTemplateNode("start", WorkflowNodeKind.Start, startTitle, startDetails.Description, 60, 90, 150, 60)
diff --git a/src/ColumnPadStudio/Workflows/WorkflowTemplateCatalog.cs b/src/ColumnPadStudio/Workflows/WorkflowTemplateCatalog.cs
index f50f0a8..8006cb4 100644
--- a/src/ColumnPadStudio/Workflows/WorkflowTemplateCatalog.cs
+++ b/src/ColumnPadStudio/Workflows/WorkflowTemplateCatalog.cs
@@ -13,7 +13,6 @@ private static IReadOnlyList BuildTemplates()
name: "Essay Plan",
category: "Writing",
description: "Shape a writing piece from thesis through evidence, structure, draft, and review.",
- trigger: WorkflowTriggerType.Manual,
nodeTitles:
[
"Define thesis",
@@ -58,7 +57,6 @@ private static IReadOnlyList BuildTemplates()
name: "Research Notes",
category: "Writing",
description: "Capture sources, key claims, quotes, gaps, and follow-up questions.",
- trigger: WorkflowTriggerType.Manual,
nodeTitles:
[
"Collect sources",
@@ -72,7 +70,6 @@ private static IReadOnlyList BuildTemplates()
name: "Content Draft Pipeline",
category: "Writing",
description: "Move a content idea from inbox to outline, draft, edit, and publish notes.",
- trigger: WorkflowTriggerType.Manual,
nodeTitles:
[
"Idea inbox",
@@ -87,7 +84,6 @@ private static IReadOnlyList BuildTemplates()
name: "Project Planning Kickoff",
category: "Project Management",
description: "Set up a clean planning board with scope, milestones, risks, and delivery notes.",
- trigger: WorkflowTriggerType.Manual,
nodeTitles:
[
"Define scope",
@@ -100,7 +96,6 @@ private static IReadOnlyList BuildTemplates()
name: "Sprint Triage Board",
category: "Engineering",
description: "Create a triage-ready layout for backlog grooming and release readiness checks.",
- trigger: WorkflowTriggerType.Manual,
nodeTitles:
[
"Collect inbox",
@@ -114,7 +109,6 @@ private static IReadOnlyList BuildTemplates()
name: "Release Checklist",
category: "Engineering",
description: "Run a release through build, smoke checks, notes, packaging, and final upload.",
- trigger: WorkflowTriggerType.Manual,
nodeTitles:
[
"Build",
@@ -159,7 +153,6 @@ private static IReadOnlyList BuildTemplates()
name: "Daily Standup Notes",
category: "Team Ops",
description: "Capture yesterday/today/blockers quickly with repeatable structure.",
- trigger: WorkflowTriggerType.OnAppStart,
nodeTitles:
[
"Yesterday",
@@ -171,7 +164,6 @@ private static IReadOnlyList BuildTemplates()
name: "Bug Investigation Log",
category: "Engineering",
description: "Track repro steps, hypotheses, evidence, and fixes in a repeatable flow.",
- trigger: WorkflowTriggerType.Manual,
startTitle: "Capture repro",
decisionTitle: "Hypothesis confirmed?",
yesTitle: "Implement fix",
@@ -182,7 +174,6 @@ private static IReadOnlyList BuildTemplates()
name: "Compare Ideas",
category: "Thinking",
description: "Compare options, decide whether one is strong enough, then capture next action.",
- trigger: WorkflowTriggerType.Manual,
startTitle: "List options",
decisionTitle: "Clear winner?",
yesTitle: "Commit to winner",
@@ -193,7 +184,6 @@ private static IReadOnlyList BuildTemplates()
name: "Decision Tree",
category: "Thinking",
description: "Start with a question, branch possible answers, and close with an action.",
- trigger: WorkflowTriggerType.Manual,
startTitle: "Define question",
decisionTitle: "Condition met?",
yesTitle: "Take path A",
@@ -204,7 +194,6 @@ private static IReadOnlyList BuildTemplates()
name: "Meeting Notes",
category: "Team Ops",
description: "Prepare agenda, capture decisions, assign actions, and review follow-up.",
- trigger: WorkflowTriggerType.Manual,
nodeTitles:
[
"Agenda",
@@ -218,7 +207,6 @@ private static IReadOnlyList BuildTemplates()
name: "SOP Builder",
category: "Operations",
description: "Draft standard operating procedures with reusable sections and checklists.",
- trigger: WorkflowTriggerType.Manual,
nodeTitles:
[
"Purpose",
diff --git a/src/ColumnPadStudio/Workflows/WorkflowTemplateDefinition.cs b/src/ColumnPadStudio/Workflows/WorkflowTemplateDefinition.cs
index 1778535..3c850bd 100644
--- a/src/ColumnPadStudio/Workflows/WorkflowTemplateDefinition.cs
+++ b/src/ColumnPadStudio/Workflows/WorkflowTemplateDefinition.cs
@@ -28,7 +28,6 @@ public sealed class WorkflowTemplateDefinition
public required string Name { get; init; }
public required string Category { get; init; }
public required string Description { get; init; }
- public WorkflowTriggerType Trigger { get; init; } = WorkflowTriggerType.Manual;
public IReadOnlyList Nodes { get; init; } = Array.Empty();
public IReadOnlyList Connections { get; init; } = Array.Empty();
@@ -87,7 +86,6 @@ public WorkflowDefinition CreateWorkflowInstance(string? customName = null)
Name = string.IsNullOrWhiteSpace(customName) ? Name : customName.Trim(),
Category = Category,
Description = Description,
- Trigger = Trigger,
Nodes = new ObservableCollection(instanceNodes),
Links = new ObservableCollection(instanceLinks),
};
diff --git a/src/ColumnPadStudio/WorkspaceColumnEditorCache.cs b/src/ColumnPadStudio/WorkspaceColumnEditorCache.cs
new file mode 100644
index 0000000..28de4ee
--- /dev/null
+++ b/src/ColumnPadStudio/WorkspaceColumnEditorCache.cs
@@ -0,0 +1,107 @@
+using ColumnPadStudio.Controls;
+using ColumnPadStudio.ViewModels;
+
+namespace ColumnPadStudio;
+
+internal sealed class WorkspaceColumnEditorCache
+{
+ private readonly Dictionary> _entriesByWorkspace = [];
+
+ public ColumnEditorControl GetOrCreate(
+ WorkspaceSession workspace,
+ string columnId,
+ ColumnViewModel column,
+ Func createEditor,
+ out ColumnEditorControl? replacedEditor)
+ {
+ ArgumentNullException.ThrowIfNull(workspace);
+ ArgumentException.ThrowIfNullOrWhiteSpace(columnId);
+ ArgumentNullException.ThrowIfNull(column);
+ ArgumentNullException.ThrowIfNull(createEditor);
+
+ if (!_entriesByWorkspace.TryGetValue(workspace, out var entries))
+ {
+ entries = new Dictionary(StringComparer.Ordinal);
+ _entriesByWorkspace.Add(workspace, entries);
+ }
+
+ if (entries.TryGetValue(columnId, out var cachedEntry))
+ {
+ if (ReferenceEquals(cachedEntry.Column, column))
+ {
+ replacedEditor = null;
+ return cachedEntry.Editor;
+ }
+
+ replacedEditor = cachedEntry.Editor;
+ }
+ else
+ {
+ replacedEditor = null;
+ }
+
+ var editor = createEditor();
+ entries[columnId] = new Entry(column, editor);
+ return editor;
+ }
+
+ public IReadOnlyList RemoveColumnsExcept(
+ WorkspaceSession workspace,
+ IReadOnlyDictionary currentColumns)
+ {
+ ArgumentNullException.ThrowIfNull(workspace);
+ ArgumentNullException.ThrowIfNull(currentColumns);
+
+ if (!_entriesByWorkspace.TryGetValue(workspace, out var entries))
+ return [];
+
+ var removedEditors = new List();
+ foreach (var (columnId, entry) in entries.ToArray())
+ {
+ if (currentColumns.TryGetValue(columnId, out var column)
+ && ReferenceEquals(column, entry.Column))
+ {
+ continue;
+ }
+
+ entries.Remove(columnId);
+ removedEditors.Add(entry.Editor);
+ }
+
+ if (entries.Count == 0)
+ _entriesByWorkspace.Remove(workspace);
+
+ return removedEditors;
+ }
+
+ public IReadOnlyList RemoveWorkspacesExcept(
+ IReadOnlySet currentWorkspaces)
+ {
+ ArgumentNullException.ThrowIfNull(currentWorkspaces);
+
+ var removedEditors = new List();
+ foreach (var workspace in _entriesByWorkspace.Keys.ToArray())
+ {
+ if (currentWorkspaces.Contains(workspace))
+ continue;
+
+ removedEditors.AddRange(_entriesByWorkspace[workspace].Values.Select(entry => entry.Editor));
+ _entriesByWorkspace.Remove(workspace);
+ }
+
+ return removedEditors;
+ }
+
+ public IReadOnlyList Clear()
+ {
+ var removedEditors = _entriesByWorkspace.Values
+ .SelectMany(entries => entries.Values)
+ .Select(entry => entry.Editor)
+ .ToArray();
+
+ _entriesByWorkspace.Clear();
+ return removedEditors;
+ }
+
+ private sealed record Entry(ColumnViewModel Column, ColumnEditorControl Editor);
+}
diff --git a/tests/ColumnPadStudio.Domain.Tests/ColumnPadStudio.Domain.Tests.csproj b/tests/ColumnPadStudio.Domain.Tests/ColumnPadStudio.Domain.Tests.csproj
index 61a899a..5ad1858 100644
--- a/tests/ColumnPadStudio.Domain.Tests/ColumnPadStudio.Domain.Tests.csproj
+++ b/tests/ColumnPadStudio.Domain.Tests/ColumnPadStudio.Domain.Tests.csproj
@@ -1,4 +1,4 @@
-
+
@@ -6,7 +6,7 @@
Exe
- net8.0
+ net10.0
enable
enable
diff --git a/tests/ColumnPadStudio.Domain.Tests/Program.cs b/tests/ColumnPadStudio.Domain.Tests/Program.cs
index 96b443b..22b4a71 100644
--- a/tests/ColumnPadStudio.Domain.Tests/Program.cs
+++ b/tests/ColumnPadStudio.Domain.Tests/Program.cs
@@ -46,6 +46,44 @@ void Check(bool condition, string message)
Check(WorkspaceConstraints.ClampColumnCount(-1) == WorkspaceConstraints.MinColumns, "WorkspaceConstraints should clamp low column counts.");
Check(WorkspaceConstraints.ClampColumnCount(100000) == WorkspaceConstraints.MaxColumns, "WorkspaceConstraints should clamp high column counts.");
Check(WorkspaceConstraints.ClampColumnCount(3) == 3, "WorkspaceConstraints should keep valid counts unchanged.");
+Check(WorkspaceConstraints.MaxColumns == 9999, "WorkspaceConstraints should retain the original high column ceiling.");
+Check(WorkspaceConstraints.ClampColumnWidth(120) == WorkspaceConstraints.MinimumColumnWidth, "WorkspaceConstraints should enforce the visual minimum column width.");
+Check(WorkspaceConstraints.ClampColumnWidth(9000) == WorkspaceConstraints.MaximumColumnWidth, "WorkspaceConstraints should enforce the maximum column width.");
+Check(WorkspaceConstraints.ClampColumnWidth(double.NaN) == WorkspaceConstraints.DefaultColumnWidth, "WorkspaceConstraints should replace invalid column widths with the default.");
+Check(!WorkspaceColumnLayout.UsesFixedColumnStrip(1, false), "A single column should always use the available viewport width.");
+Check(WorkspaceColumnLayout.UsesFixedColumnStrip(2, false), "Multiple columns should use fixed widths when Fit to window is off.");
+Check(!WorkspaceColumnLayout.UsesFixedColumnStrip(2, true), "Fit to window should give multiple columns equal flexible widths.");
+Check(WorkspaceColumnLayout.ResolveColumnWidth(null, 444) == 444, "An unsized column should resolve against the preferred default width.");
+Check(WorkspaceColumnLayout.ResolveColumnWidth(0, 444) == 444, "A legacy zero width should resolve against the preferred default width.");
+Check(WorkspaceColumnLayout.ResolveColumnWidth(null, 9000) == WorkspaceConstraints.MaximumColumnWidth, "An invalid preferred default width should be clamped safely.");
+Check(WorkspaceColumnLayout.ResolveColumnWidth(555, 444) == 555, "An explicit column width should override the preferred default.");
+Check(
+ WorkspaceColumnLayout.CalculateHostWidth([5000], 1200, 4, true, false, 444) == 1200,
+ "A single column should fill its viewport even when it has a stored custom width.");
+Check(
+ WorkspaceColumnLayout.CalculateHostWidth([null, null, null], 1200, 4, true, false, 320) == 1200,
+ "A fixed-width column strip should still fill unused viewport space.");
+Check(
+ WorkspaceColumnLayout.CalculateHostWidth([null, null, null, null], 1200, 4, true, false, 320) == 1292,
+ "Four default columns plus their gaps should overflow a 1200px viewport and enable horizontal scrolling.");
+Check(
+ WorkspaceColumnLayout.CalculateHostWidth([444, null], 700, 4, true, false, 320) == 768,
+ "Host width should preserve explicit widths while defaulting unsized neighbours.");
+Check(
+ WorkspaceColumnLayout.CalculateHostWidth([444, null], 700, 4, false, false, 320) == 764,
+ "Turning snapping off should remove the gap without changing fixed column widths.");
+Check(
+ WorkspaceColumnLayout.CalculateHostWidth([444, 555, 666], 1200, 4, true, true, 480) == 1200,
+ "Fit mode should ignore stored and preferred widths while there is enough viewport space.");
+Check(
+ WorkspaceColumnLayout.CalculateHostWidth([444, 555, 666, 777, 888, 999], 1200, 4, true, true, 480) == 1340,
+ "Fit mode should preserve every column's safe minimum width plus snapped gaps when space is tight.");
+Check(
+ WorkspaceColumnLayout.CalculateHostWidth([444, 555, 666, 777, 888, 999], 1200, 4, false, true, 480) == 1320,
+ "Unsnapped Fit mode should preserve safe minimum widths without adding gaps.");
+Check(
+ WorkspaceColumnLayout.CalculateHostWidth([null, null], 500, 4, true, false, 444) == 892,
+ "Fixed mode should apply the preferred custom width to every unsized column.");
var textExport = $"{WorkspaceImportRules.TextExportMarker}\n{WorkspaceImportRules.TextExportFormatLine}\n\n===== Alpha =====\n\none\n\n===== Beta =====\n\n.\n";
Check(WorkspaceImportRules.LooksLikeTextExport(textExport), "Text-export detection should recognize marked ColumnPad exports.");
Check(!WorkspaceImportRules.LooksLikeTextExport("plain note\nline two"), "Text-export detection should reject plain text.");
@@ -56,15 +94,23 @@ void Check(bool condition, string message)
Check(parsedTextExport[0].Title == "Alpha" && parsedTextExport[0].Text == "one", "Text-export parser should preserve first section content.");
Check(parsedTextExport[1].Title == "Beta" && parsedTextExport[1].Text == ".", "Text-export parser should preserve second section content.");
-var markdownExport = $"{WorkspaceImportRules.MarkdownExportMarker}\n\n## Red\n\nleft\n\n## Blue\n\nright\n";
-Check(WorkspaceImportRules.LooksLikeMarkdownExport(markdownExport), "Markdown-export detection should recognize marked ColumnPad markdown exports.");
-Check(!WorkspaceImportRules.LooksLikeMarkdownExport("intro paragraph\n## later heading"), "Markdown-export detection should reject inline heading text exports.");
-Check(!WorkspaceImportRules.LooksLikeMarkdownExport("## Red\n\nleft\n\n## Blue\n\nright\n"), "Markdown-export detection should reject unmarked ordinary markdown headings.");
-
-var parsedMarkdownExport = WorkspaceImportRules.ParseMarkdownExportColumns(markdownExport);
-Check(parsedMarkdownExport.Count == 2, "Markdown-export parser should return one column per heading.");
-Check(parsedMarkdownExport[0].Title == "Red" && parsedMarkdownExport[0].Text == "left", "Markdown-export parser should preserve first heading section.");
-Check(parsedMarkdownExport[1].Title == "Blue" && parsedMarkdownExport[1].Text == "right", "Markdown-export parser should preserve second heading section.");
+var jsonExport = """
+{
+ "FileType": "ColumnPadTextExport",
+ "Version": 1,
+ "Columns": [
+ { "Title": "Red", "Text": "left" },
+ { "Title": "Blue", "Text": "right" }
+ ]
+}
+""";
+Check(WorkspaceImportRules.IsJsonExport(jsonExport), "JSON-export detection should recognize marked ColumnPad text exports.");
+Check(!WorkspaceImportRules.IsJsonExport("{\"FileType\":\"Other\",\"Columns\":[]}"), "JSON-export detection should reject unrelated JSON.");
+
+var parsedJsonExport = WorkspaceImportRules.ParseJsonExportColumns(jsonExport);
+Check(parsedJsonExport.Count == 2, "JSON-export parser should return one column per JSON entry.");
+Check(parsedJsonExport[0].Title == "Red" && parsedJsonExport[0].Text == "left", "JSON-export parser should preserve first column content.");
+Check(parsedJsonExport[1].Title == "Blue" && parsedJsonExport[1].Text == "right", "JSON-export parser should preserve second column content.");
var sessionJson = "{\"Version\":1,\"ActiveWorkspaceIndex\":0,\"Workspaces\":[{\"Name\":\"A\",\"LayoutJson\":\"{}\"}]}";
Check(WorkspaceImportRules.IsWorkspaceSessionJson(sessionJson), "Workspace-session detection should recognize Workspaces arrays.");
diff --git a/tests/ColumnPadStudio.SmokeTests/AutoRecoverySmokeTests.cs b/tests/ColumnPadStudio.SmokeTests/AutoRecoverySmokeTests.cs
new file mode 100644
index 0000000..fbad31a
--- /dev/null
+++ b/tests/ColumnPadStudio.SmokeTests/AutoRecoverySmokeTests.cs
@@ -0,0 +1,250 @@
+using ColumnPadStudio.Models;
+using ColumnPadStudio.Services;
+using ColumnPadStudio.ViewModels;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Text.Json.Nodes;
+
+namespace ColumnPadStudio.SmokeTests;
+
+internal static class AutoRecoverySmokeTests
+{
+ private static readonly TimeSpan AsyncTestTimeout = TimeSpan.FromSeconds(5);
+
+ public static async Task RunAsync(SmokeTestContext tests)
+ {
+ await CheckSnapshotSerializationAsync(tests);
+ await CheckLatestWriteCoalescingAsync(tests);
+ await CheckCleanCloseCancellationAsync(tests);
+ await CheckCancelledCloseResumeAsync(tests);
+ CheckRecoveryStoreCancellation(tests);
+ }
+
+ private static async Task CheckSnapshotSerializationAsync(SmokeTestContext tests)
+ {
+ var vm = new MainViewModel();
+ vm.Columns[0].Title = "Captured column";
+ vm.Columns[0].Text = "captured text";
+
+ var directJson = vm.ToLayoutJson();
+ var capturedSnapshot = vm.CaptureRecoveryLayoutSnapshot();
+ var backgroundJson = await Task.Run(() => MainViewModel.SerializeLayoutSnapshot(capturedSnapshot));
+ tests.Check(
+ string.Equals(directJson, backgroundJson, StringComparison.Ordinal),
+ "A UI-captured recovery layout should serialize identically on a background thread.");
+
+ vm.Columns[0].Title = "Changed later";
+ vm.Columns[0].Text = "changed text";
+ var serializedCapture = await Task.Run(() => MainViewModel.SerializeLayoutSnapshot(capturedSnapshot));
+ var capturedColumn = JsonNode.Parse(serializedCapture)?["Columns"]?[0]?.AsObject();
+ tests.Check(
+ capturedColumn?["Title"]?.GetValue() == "Captured column" &&
+ capturedColumn["Text"]?.GetValue() == "captured text",
+ "A captured recovery layout should remain detached from later UI model changes.");
+ }
+
+ private static async Task CheckLatestWriteCoalescingAsync(SmokeTestContext tests)
+ {
+ var writes = new ConcurrentQueue();
+ var results = new ConcurrentQueue();
+ var firstWriteStarted = NewSignal();
+ var releaseFirstWrite = NewSignal();
+ var concurrentWrites = 0;
+ var maximumConcurrentWrites = 0;
+
+ using var writer = new LatestWriteCoordinator(
+ async (value, cancellationToken) =>
+ {
+ var currentWrites = Interlocked.Increment(ref concurrentWrites);
+ UpdateMaximum(ref maximumConcurrentWrites, currentWrites);
+ writes.Enqueue(value);
+ try
+ {
+ if (value == 1)
+ {
+ firstWriteStarted.TrySetResult(true);
+ await releaseFirstWrite.Task.WaitAsync(cancellationToken);
+ }
+ }
+ finally
+ {
+ Interlocked.Decrement(ref concurrentWrites);
+ }
+ },
+ results.Enqueue);
+
+ writer.Queue(1);
+ await firstWriteStarted.Task.WaitAsync(AsyncTestTimeout);
+ writer.Queue(2);
+ writer.Queue(3);
+ releaseFirstWrite.TrySetResult(true);
+ await writer.WaitForIdleAsync().WaitAsync(AsyncTestTimeout);
+
+ tests.Check(
+ writes.SequenceEqual([1, 3]),
+ "Auto-recovery should finish the active write and coalesce queued ticks to the newest snapshot.");
+ tests.Check(
+ maximumConcurrentWrites == 1,
+ "Auto-recovery should never run more than one recovery write at a time.");
+ tests.Check(
+ results.Count == 2 && results.All(result => result is null),
+ "Successful coalesced recovery writes should report observed completion.");
+ }
+
+ private static async Task CheckCleanCloseCancellationAsync(SmokeTestContext tests)
+ {
+ var writeStarted = NewSignal();
+ var closeEvents = new ConcurrentQueue();
+
+ using var writer = new LatestWriteCoordinator(
+ async (_, cancellationToken) =>
+ {
+ closeEvents.Enqueue("write-started");
+ writeStarted.TrySetResult(true);
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ closeEvents.Enqueue("published");
+ },
+ _ => { });
+
+ writer.Queue(1);
+ await writeStarted.Task.WaitAsync(AsyncTestTimeout);
+ await writer.PauseAsync().WaitAsync(AsyncTestTimeout);
+
+ closeEvents.Enqueue("cleared");
+ await Task.Delay(25);
+ tests.Check(
+ closeEvents.SequenceEqual(["write-started", "cleared"]),
+ "A clean close should cancel and drain an already-started save before recovery is cleared.");
+ }
+
+ private static async Task CheckCancelledCloseResumeAsync(SmokeTestContext tests)
+ {
+ var tokens = new ConcurrentQueue();
+ var writes = new ConcurrentQueue();
+ var firstWriteStarted = NewSignal();
+
+ using var writer = new LatestWriteCoordinator(
+ async (value, cancellationToken) =>
+ {
+ tokens.Enqueue(cancellationToken);
+ if (value == 1)
+ {
+ firstWriteStarted.TrySetResult(true);
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ return;
+ }
+
+ writes.Enqueue(value);
+ },
+ _ => { });
+
+ writer.Queue(1);
+ await firstWriteStarted.Task.WaitAsync(AsyncTestTimeout);
+ await writer.PauseAsync().WaitAsync(AsyncTestTimeout);
+ writer.Resume();
+ writer.Queue(2);
+ await writer.WaitForIdleAsync().WaitAsync(AsyncTestTimeout);
+
+ var observedTokens = tokens.ToArray();
+ tests.Check(
+ observedTokens.Length == 2 &&
+ observedTokens[0].IsCancellationRequested &&
+ !observedTokens[1].IsCancellationRequested &&
+ writes.SequenceEqual([2]),
+ "Cancelling a close prompt should resume auto-recovery with a fresh usable cancellation token.");
+ }
+
+ private static void CheckRecoveryStoreCancellation(SmokeTestContext tests)
+ {
+ var recoveryRoot = Path.Combine(Path.GetTempPath(), $"columnpad-recovery-cancel-{Guid.NewGuid():N}");
+ var failedActivationRoot = Path.Combine(Path.GetTempPath(), $"columnpad-recovery-activation-{Guid.NewGuid():N}");
+ try
+ {
+ var originalWorkspace = new WorkspaceRecoveryWorkspace(
+ "Original",
+ "original-layout",
+ null,
+ SaveFileKind.TextDocument,
+ IsDirty: true,
+ RequiresSaveAsBeforeOverwrite: false);
+ WorkspaceRecoveryStore.Save([originalWorkspace], 0, recoveryRoot);
+
+ var pointerPath = Path.Combine(recoveryRoot, "current-generation.txt");
+ var originalGeneration = File.ReadAllText(pointerPath);
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+
+ var cancellationObserved = false;
+ try
+ {
+ WorkspaceRecoveryStore.Save(
+ [originalWorkspace with { Name = "Cancelled", LayoutJson = "cancelled-layout" }],
+ 0,
+ recoveryRoot,
+ cancellation.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ cancellationObserved = true;
+ }
+
+ tests.Check(
+ cancellationObserved && File.ReadAllText(pointerPath) == originalGeneration &&
+ WorkspaceRecoveryStore.TryLoad(out var preservedSnapshot, recoveryRoot) &&
+ preservedSnapshot.Workspaces[0].LayoutJson == "original-layout",
+ "A cancelled recovery generation should leave the prior pointer and snapshot loadable.");
+
+ var generationDirectory = Path.Combine(recoveryRoot, originalGeneration.Trim());
+ var manifestPath = Path.Combine(generationDirectory, "manifest.json");
+ var manifest = JsonNode.Parse(File.ReadAllText(manifestPath))!.AsObject();
+ manifest["Workspaces"]![0]!["CurrentFileKind"] = "999";
+ File.WriteAllText(manifestPath, manifest.ToJsonString());
+ tests.Check(
+ WorkspaceRecoveryStore.TryLoad(out var normalizedSnapshot, recoveryRoot) &&
+ normalizedSnapshot.Workspaces[0].CurrentFileKind == SaveFileKind.Layout,
+ "Recovery loading should normalize undefined numeric file kinds to a safe layout default.");
+
+ Directory.CreateDirectory(Path.Combine(failedActivationRoot, "current-generation.txt"));
+ var activationFailed = false;
+ try
+ {
+ WorkspaceRecoveryStore.Save([originalWorkspace], 0, failedActivationRoot);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ activationFailed = true;
+ }
+
+ tests.Check(
+ activationFailed &&
+ Directory.GetDirectories(failedActivationRoot, "generation-*").Length == 0 &&
+ !WorkspaceRecoveryStore.TryLoad(out _, failedActivationRoot),
+ "A generation that fails before pointer activation should be removed instead of becoming fallback recovery.");
+ }
+ finally
+ {
+ if (Directory.Exists(recoveryRoot))
+ Directory.Delete(recoveryRoot, recursive: true);
+ if (Directory.Exists(failedActivationRoot))
+ Directory.Delete(failedActivationRoot, recursive: true);
+ }
+ }
+
+ private static TaskCompletionSource NewSignal()
+ {
+ return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+
+ private static void UpdateMaximum(ref int maximum, int candidate)
+ {
+ var current = Volatile.Read(ref maximum);
+ while (candidate > current)
+ {
+ var observed = Interlocked.CompareExchange(ref maximum, candidate, current);
+ if (observed == current)
+ return;
+
+ current = observed;
+ }
+ }
+}
diff --git a/tests/ColumnPadStudio.SmokeTests/ColumnPadStudio.SmokeTests.csproj b/tests/ColumnPadStudio.SmokeTests/ColumnPadStudio.SmokeTests.csproj
index dc9bdfe..8aba2e4 100644
--- a/tests/ColumnPadStudio.SmokeTests/ColumnPadStudio.SmokeTests.csproj
+++ b/tests/ColumnPadStudio.SmokeTests/ColumnPadStudio.SmokeTests.csproj
@@ -1,8 +1,8 @@
-
+
Exe
- net8.0-windows
+ net10.0-windows
enable
enable
true
diff --git a/tests/ColumnPadStudio.SmokeTests/EditorServiceSmokeTests.cs b/tests/ColumnPadStudio.SmokeTests/EditorServiceSmokeTests.cs
new file mode 100644
index 0000000..f8b4031
--- /dev/null
+++ b/tests/ColumnPadStudio.SmokeTests/EditorServiceSmokeTests.cs
@@ -0,0 +1,69 @@
+using ColumnPadStudio.Domain.Lists;
+using ColumnPadStudio.Services;
+using ColumnPadStudio.ViewModels;
+
+namespace ColumnPadStudio.SmokeTests;
+
+internal static class EditorServiceSmokeTests
+{
+ public static void Run(SmokeTestContext tests)
+ {
+ var searchColumns = new List
+ {
+ "alpha beta",
+ "gamma\nalpha",
+ string.Empty
+ };
+
+ tests.Check(TextSearchService.TryFindNext(searchColumns, "alpha", 0, 0, 0, SearchCursor.Empty, out var firstFind), "Text search service should find the first match from the active column.");
+ tests.Check(firstFind.ColumnIndex == 0 && firstFind.CharIndex == 0 && firstFind.LineNumber == 1, "Text search service should report first-column hit coordinates.");
+ tests.Check(TextSearchService.TryFindNext(searchColumns, "alpha", 0, 0, 0, new SearchCursor(firstFind.ColumnIndex, firstFind.CharIndex), out var secondFind), "Text search service should advance to the next match after the cursor.");
+ tests.Check(secondFind.ColumnIndex == 1 && secondFind.CharIndex == 6 && secondFind.LineNumber == 2, "Text search service should report line/char for cross-column next hit.");
+ tests.Check(TextSearchService.TryFindNext(searchColumns, "alpha", 0, 0, 0, new SearchCursor(secondFind.ColumnIndex, secondFind.CharIndex), out var wrappedFind), "Text search service should wrap when searching past the last match.");
+ tests.Check(wrappedFind.ColumnIndex == 0 && wrappedFind.CharIndex == 0, "Text search service wrap search should return to the first match.");
+ tests.Check(!TextSearchService.TryFindNext(searchColumns, "missing", 0, 0, 0, SearchCursor.Empty, out _), "Text search service should return no hit when the term is absent.");
+
+ var (replacedTextByService, replacementCountByService) = TextSearchService.ReplaceAllWithCount("one One one", "one", "two", StringComparison.CurrentCultureIgnoreCase);
+ tests.Check(replacementCountByService == 3, "Text search service replace should count all case-insensitive hits.");
+ tests.Check(replacedTextByService == "two two two", "Text search service replace should substitute all hits in order.");
+ tests.Check(TextSearchService.ComputeLineNumber("a\nb\nc", 4) == 3, "Text search service should compute 1-based line numbers from character index.");
+ tests.Check(TextSearchService.ComputeLineNumber("a\rb\r\nc", 5) == 3, "Text search service should count LF, CRLF, and standalone CR line breaks consistently.");
+
+ var listModeVm = new ColumnViewModel
+ {
+ Text = "alpha\nbeta",
+ LineMarkerMode = LineMarkerMode.Bullets
+ };
+ tests.Check(listModeVm.LineMarkerMode == LineMarkerMode.Bullets, "Line marker mode should support bullets without mutating text.");
+ var initialGutterStateVersion = listModeVm.GutterStateVersion;
+ listModeVm.LineMarkerMode = LineMarkerMode.Checklist;
+ tests.Check(listModeVm.GutterStateVersion > initialGutterStateVersion, "Changing the gutter mode should invalidate its cached rendering.");
+ var checklistGutterStateVersion = listModeVm.GutterStateVersion;
+ listModeVm.ToggleChecklistLineChecked(0);
+ tests.Check(listModeVm.GutterStateVersion > checklistGutterStateVersion, "Changing a checklist marker should invalidate its cached rendering.");
+ tests.Check(listModeVm.IsChecklistLineChecked(0), "Checklist gutter mode should toggle checks without inserting inline symbols.");
+ tests.Check(listModeVm.Text == "alpha\nbeta", "Checklist gutter mode should keep body text unchanged.");
+
+ var expectedClipboardLines = string.Join(Environment.NewLine, "one", string.Empty, "two", "three");
+ tests.Check(
+ ClipboardTextService.NormalizeClipboardText("one\r\r\ntwo\u2028three") == expectedClipboardLines,
+ "Clipboard text normalization should preserve every line break while normalizing newline characters.");
+
+ var alternatingBlankPaste = "one\n\n two\n\nthree\n\nfour";
+ tests.Check(
+ ClipboardTextService.NormalizeClipboardText(alternatingBlankPaste) == alternatingBlankPaste.Replace("\n", Environment.NewLine, StringComparison.Ordinal),
+ "Clipboard text normalization should preserve intentional alternating blank rows.");
+
+ tests.Check(
+ ClipboardTextService.ApplyPastePreset("alpha\n beta", PasteListPreset.Bullets) == string.Join(Environment.NewLine, "- alpha", " - beta"),
+ "Clipboard bullet preset should add markdown bullets while preserving indentation.");
+ tests.Check(
+ ClipboardTextService.ApplyPastePreset("- [x] done\nplain", PasteListPreset.Checklist) == string.Join(Environment.NewLine, "- [x] done", "- [ ] plain"),
+ "Clipboard checklist preset should preserve checked checklist rows and add unchecked markers to plain rows.");
+ tests.Check(
+ ClipboardTextService.ApplyPastePreset("1. ordered", PasteListPreset.Bullets) == "1. ordered",
+ "Clipboard paste presets should not rewrite ordered-list prefixes.");
+
+ ImageSafetySmokeTests.Run(tests);
+ }
+}
diff --git a/tests/ColumnPadStudio.SmokeTests/ImageSafetySmokeTests.cs b/tests/ColumnPadStudio.SmokeTests/ImageSafetySmokeTests.cs
new file mode 100644
index 0000000..8329c36
--- /dev/null
+++ b/tests/ColumnPadStudio.SmokeTests/ImageSafetySmokeTests.cs
@@ -0,0 +1,45 @@
+using ColumnPadStudio.Services;
+using ColumnPadStudio.ViewModels;
+using System.IO;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace ColumnPadStudio.SmokeTests;
+
+internal static class ImageSafetySmokeTests
+{
+ public static void Run(SmokeTestContext tests)
+ {
+ var encoder = new PngBitmapEncoder();
+ encoder.Frames.Add(BitmapFrame.Create(BitmapSource.Create(
+ 1,
+ 3000,
+ 96,
+ 96,
+ PixelFormats.Bgra32,
+ null,
+ new byte[3000 * 4],
+ 4)));
+
+ byte[] content;
+ using (var stream = new MemoryStream())
+ {
+ encoder.Save(stream);
+ content = stream.ToArray();
+ }
+
+ var image = new ColumnImageViewModel(
+ string.Empty,
+ "tall.png",
+ pixelWidth: 1,
+ pixelHeight: 1,
+ imageContent: content);
+
+ tests.Check(
+ image.PixelWidth == 1 && image.PixelHeight == 3000,
+ "Loaded pictures should derive their dimensions from actual bytes instead of trusting saved metadata.");
+ tests.Check(
+ image.DisplaySource is BitmapSource { PixelHeight: <= 2000 },
+ "Tall pictures should be downscaled by their longest dimension before display decoding.");
+ }
+}
diff --git a/tests/ColumnPadStudio.SmokeTests/InfrastructureSmokeTests.cs b/tests/ColumnPadStudio.SmokeTests/InfrastructureSmokeTests.cs
new file mode 100644
index 0000000..c8e1eac
--- /dev/null
+++ b/tests/ColumnPadStudio.SmokeTests/InfrastructureSmokeTests.cs
@@ -0,0 +1,278 @@
+using ColumnPadStudio.Models;
+using ColumnPadStudio.Services;
+using System.IO;
+using System.Net.Http;
+using System.Text.Json.Nodes;
+
+namespace ColumnPadStudio.SmokeTests;
+
+internal static class InfrastructureSmokeTests
+{
+ public static async Task RunAsync(SmokeTestContext tests)
+ {
+ var preferencesPath = Path.Combine(Path.GetTempPath(), $"columnpad-preferences-{Guid.NewGuid():N}.json");
+ try
+ {
+ AppPreferencesService.Save(
+ new AppPreferences(
+ "Dark Mode",
+ SnapAllColumnsEnabled: false,
+ ColumnSpacingPx: 18,
+ FitColumnsToWindow: false,
+ DefaultColumnWidthPx: 444),
+ preferencesPath);
+ var loadedPreferences = AppPreferencesService.Load(preferencesPath);
+ tests.Check(loadedPreferences.ThemePreset == "Dark Mode", "Saved app preferences should round-trip the selected theme.");
+ tests.Check(!loadedPreferences.SnapAllColumnsEnabled, "Saved app preferences should round-trip the global unsnap-all choice.");
+ tests.Check(loadedPreferences.ColumnSpacingPx == 18, "Saved app preferences should round-trip the column gap.");
+ tests.Check(!loadedPreferences.FitColumnsToWindow, "Saved app preferences should keep Fit to window independent from snapping.");
+ tests.Check(loadedPreferences.DefaultColumnWidthPx == 444, "Saved app preferences should round-trip a custom default column width.");
+ AppPreferencesService.Save(loadedPreferences with { FitColumnsToWindow = true }, preferencesPath);
+ var fittedPreferences = AppPreferencesService.Load(preferencesPath);
+ tests.Check(fittedPreferences.FitColumnsToWindow, "Saved app preferences should round-trip the explicit Fit-to-window choice.");
+ tests.Check(!fittedPreferences.SnapAllColumnsEnabled, "Changing Fit should not change the independent snapping choice.");
+ AppPreferencesService.Save(
+ new AppPreferences(ColumnSpacingPx: 999, DefaultColumnWidthPx: 99999),
+ preferencesPath);
+ tests.Check(
+ AppPreferencesService.Load(preferencesPath).ColumnSpacingPx == AppPreferences.MaximumColumnSpacingPx,
+ "Saved app preferences should clamp an excessive column gap.");
+ tests.Check(
+ AppPreferencesService.Load(preferencesPath).DefaultColumnWidthPx == (int)ColumnPadStudio.Domain.Workspaces.WorkspaceConstraints.MaximumColumnWidth,
+ "Saved app preferences should clamp an excessive default column width.");
+ AppPreferencesService.Save(new AppPreferences(DefaultColumnWidthPx: -1), preferencesPath);
+ tests.Check(
+ AppPreferencesService.Load(preferencesPath).DefaultColumnWidthPx == (int)ColumnPadStudio.Domain.Workspaces.WorkspaceConstraints.MinimumColumnWidth,
+ "Saved app preferences should clamp a default column width below the safe minimum.");
+ File.WriteAllText(preferencesPath, "{\"ThemePreset\":\"Light Mode\",\"SnapAllColumnsEnabled\":false,\"ColumnSpacingPx\":10}");
+ var legacyPreferences = AppPreferencesService.Load(preferencesPath);
+ tests.Check(legacyPreferences.ThemePreset == "Light Mode", "Older app preferences should preserve their saved theme.");
+ tests.Check(!legacyPreferences.SnapAllColumnsEnabled, "Older app preferences should preserve their global snapping choice.");
+ tests.Check(!legacyPreferences.FitColumnsToWindow, "Older app preferences should default to fixed widths now that Fit is independent from snapping.");
+ tests.Check(
+ legacyPreferences.DefaultColumnWidthPx == AppPreferences.StandardColumnWidthPx,
+ "Older app preferences should migrate to the standard default column width.");
+ tests.Check(
+ legacyPreferences.ColumnSpacingPx == 10,
+ "Older app preferences should preserve the saved column gap.");
+ File.WriteAllText(preferencesPath, "{\"ThemePreset\":\"Light Mode\",\"SnapColumnsEnabled\":false,\"ColumnSpacingPx\":10}");
+ var retiredPreferences = AppPreferencesService.Load(preferencesPath);
+ tests.Check(retiredPreferences.SnapAllColumnsEnabled, "The current global snap setting should default on when only the retired field exists.");
+ tests.Check(!retiredPreferences.FitColumnsToWindow, "A file predating global snap should use the new fixed-width default.");
+ tests.Check(
+ retiredPreferences.DefaultColumnWidthPx == AppPreferences.StandardColumnWidthPx,
+ "A file predating default widths should use the standard 320px width.");
+ File.WriteAllText(preferencesPath, "{\"ThemePreset\":\"Dark Mode\"}");
+ var oldestPreferences = AppPreferencesService.Load(preferencesPath);
+ tests.Check(oldestPreferences.SnapAllColumnsEnabled, "The oldest preference format should migrate to snapping on.");
+ tests.Check(
+ oldestPreferences.ColumnSpacingPx == AppPreferences.DefaultColumnSpacingPx,
+ "The oldest preference format should migrate to the standard column gap.");
+ tests.Check(!oldestPreferences.FitColumnsToWindow, "The oldest preference format should migrate to fixed column widths.");
+ tests.Check(
+ oldestPreferences.DefaultColumnWidthPx == AppPreferences.StandardColumnWidthPx,
+ "The oldest preference format should migrate to the standard 320px width.");
+ File.WriteAllText(preferencesPath, "{not valid json");
+ var fallbackPreferences = AppPreferencesService.Load(out var preferencesWarning, preferencesPath);
+ tests.Check(fallbackPreferences.ThemePreset == "Default Mode", "Invalid app preferences should fall back to the default theme.");
+ tests.Check(fallbackPreferences.SnapAllColumnsEnabled, "Invalid app preferences should fall back to global snapping on.");
+ tests.Check(!fallbackPreferences.FitColumnsToWindow, "Invalid app preferences should fall back to fixed column widths.");
+ tests.Check(
+ fallbackPreferences.DefaultColumnWidthPx == AppPreferences.StandardColumnWidthPx,
+ "Invalid app preferences should fall back to the standard default column width.");
+ tests.Check(
+ fallbackPreferences.ColumnSpacingPx == AppPreferences.DefaultColumnSpacingPx,
+ "Invalid app preferences should use the default column gap.");
+ tests.Check(!string.IsNullOrWhiteSpace(preferencesWarning), "Invalid app preferences should report that defaults were used.");
+ tests.Check(!File.Exists(preferencesPath), "Invalid app preferences should be moved out of the active settings path.");
+ tests.Check(
+ Directory.GetFiles(
+ Path.GetDirectoryName(preferencesPath)!,
+ Path.GetFileName(preferencesPath) + ".invalid-*").Length == 1,
+ "Invalid app preferences should be retained as one recoverable backup.");
+ }
+ finally
+ {
+ if (File.Exists(preferencesPath))
+ File.Delete(preferencesPath);
+
+ foreach (var invalidPath in Directory.GetFiles(
+ Path.GetDirectoryName(preferencesPath)!,
+ Path.GetFileName(preferencesPath) + ".invalid-*"))
+ {
+ File.Delete(invalidPath);
+ }
+ }
+
+ tests.Check(
+ AppStoragePaths.CrashLogsDirectory == Path.Combine(AppStoragePaths.RootDirectory, "CrashLogs"),
+ "App storage paths should expose the crash-log directory as a single source of truth.");
+ tests.Check(
+ typeof(MainWindow).Assembly.GetName().Name == "ColumnPadStudio",
+ "The application assembly should publish with the stable ColumnPadStudio executable name.");
+
+ const string latestReleaseJson = """
+ {
+ "tag_name": "v2.4.0",
+ "html_url": "https://github.com/example-owner/ColumnPadStudio/releases/tag/v2.4.0"
+ }
+ """;
+ using (var updateHttpClient = new HttpClient(new StaticJsonResponseHandler(latestReleaseJson)))
+ {
+ var updateService = new GitHubReleaseUpdateService(updateHttpClient);
+ var latestRelease = await updateService.GetLatestReleaseAsync();
+
+ tests.Check(latestRelease?.Version == new Version(2, 4, 0, 0), "GitHub update checks should parse release tags into comparable versions.");
+ tests.Check(latestRelease?.DisplayVersion == "v2.4.0", "GitHub update checks should keep a clean version label for the notification.");
+ tests.Check(latestRelease?.ReleasePage.AbsoluteUri == "https://github.com/example-owner/ColumnPadStudio/releases/tag/v2.4.0", "GitHub update checks should preserve the official HTTPS release page.");
+ tests.Check(
+ latestRelease is not null && GitHubReleaseUpdateService.IsNewerRelease(latestRelease.Version, new Version(2, 3, 0, 0)),
+ "GitHub update checks should detect a newer stable release.");
+ tests.Check(
+ latestRelease is not null && !GitHubReleaseUpdateService.IsNewerRelease(latestRelease.Version, new Version(2, 4, 0, 0)),
+ "GitHub update checks should not notify for the installed release.");
+ }
+
+ const string untrustedReleasePageJson = """
+ {
+ "tag_name": "v2.4.0",
+ "html_url": "https://example.com/not-columnpad"
+ }
+ """;
+ using (var updateHttpClient = new HttpClient(new StaticJsonResponseHandler(untrustedReleasePageJson)))
+ {
+ var updateService = new GitHubReleaseUpdateService(updateHttpClient);
+ var latestRelease = await updateService.GetLatestReleaseAsync();
+ tests.Check(
+ latestRelease?.ReleasePage == GitHubReleaseUpdateService.ReleasesPageUri,
+ "Update links should fall back to the trusted ColumnPadStudio GitHub releases page.");
+ }
+
+ using (var updateHttpClient = new HttpClient(
+ new StaticJsonResponseHandler("{}", System.Net.HttpStatusCode.NotFound)))
+ {
+ var updateService = new GitHubReleaseUpdateService(updateHttpClient);
+ tests.Check(
+ await updateService.GetLatestReleaseAsync() is null,
+ "Update checks should quietly handle a repository with no published release.");
+ }
+
+ tests.Check(
+ GitHubReleaseUpdateService.TryParseReleaseVersion("v2.5.0-beta.1", out var parsedReleaseVersion)
+ && parsedReleaseVersion == new Version(2, 5, 0, 0),
+ "Release version parsing should ignore semantic-version labels when comparing versions.");
+ tests.Check(
+ !GitHubReleaseUpdateService.TryParseReleaseVersion("latest", out _),
+ "Release version parsing should reject tags that do not contain a numeric version.");
+
+ var atomicRoot = Path.Combine(Path.GetTempPath(), $"columnpad-atomic-{Guid.NewGuid():N}");
+ try
+ {
+ var atomicPath = Path.Combine(atomicRoot, "nested", "note.txt");
+ AtomicFileWriter.WriteText(atomicPath, "first");
+ tests.Check(File.ReadAllText(atomicPath) == "first", "Atomic writer should create missing target directories.");
+ AtomicFileWriter.WriteText(atomicPath, "second");
+ tests.Check(File.ReadAllText(atomicPath) == "second", "Atomic writer should replace existing files cleanly.");
+ tests.Check(Directory.GetFiles(Path.GetDirectoryName(atomicPath)!, "*.tmp").Length == 0, "Atomic writer should clean up temporary files after a successful write.");
+ }
+ finally
+ {
+ if (Directory.Exists(atomicRoot))
+ Directory.Delete(atomicRoot, recursive: true);
+ }
+
+ var maximumWorkspaceEntries = Enumerable
+ .Range(1, WorkspaceSessionFileService.MaxWorkspaces)
+ .Select(index => new WorkspaceSessionEntryData($"Workspace {index}", "{}", 3))
+ .ToList();
+ var maximumWorkspaceSessionJson = WorkspaceSessionFileService.SerializeSession(maximumWorkspaceEntries, 0);
+ tests.Check(
+ WorkspaceSessionFileService.TryParseSession(maximumWorkspaceSessionJson, out var maximumWorkspaceSession) &&
+ maximumWorkspaceSession.Workspaces.Count == WorkspaceSessionFileService.MaxWorkspaces,
+ "Workspace sessions should accept the shared maximum workspace count.");
+
+ var oversizedWorkspaceEntries = maximumWorkspaceEntries
+ .Append(new WorkspaceSessionEntryData("Workspace 65", "{}", 3))
+ .ToList();
+ var oversizedSessionSaveRejected = false;
+ try
+ {
+ _ = WorkspaceSessionFileService.SerializeSession(oversizedWorkspaceEntries, 0);
+ }
+ catch (ArgumentException)
+ {
+ oversizedSessionSaveRejected = true;
+ }
+
+ tests.Check(
+ oversizedSessionSaveRejected,
+ "Workspace-session saves should reject more than the shared maximum workspace count.");
+
+ var oversizedSessionRoot = JsonNode.Parse(maximumWorkspaceSessionJson)!.AsObject();
+ oversizedSessionRoot["Workspaces"]!.AsArray().Add(new JsonObject
+ {
+ ["Name"] = "Workspace 65",
+ ["Layout"] = new JsonObject(),
+ ["LastMultiColumnCount"] = 3
+ });
+ var oversizedWorkspaceSessionJson = oversizedSessionRoot.ToJsonString();
+ tests.Check(
+ !WorkspaceSessionFileService.IsWorkspaceSessionJson(oversizedWorkspaceSessionJson) &&
+ !WorkspaceSessionFileService.TryParseSession(oversizedWorkspaceSessionJson, out _),
+ "Workspace-session detection and loading should reject more than the shared maximum workspace count.");
+
+ var recoveryLimitRoot = Path.Combine(Path.GetTempPath(), $"columnpad-recovery-limit-{Guid.NewGuid():N}");
+ try
+ {
+ var oversizedRecoveryWorkspaces = Enumerable
+ .Range(1, WorkspaceSessionFileService.MaxWorkspaces + 1)
+ .Select(index => new WorkspaceRecoveryWorkspace(
+ $"Workspace {index}",
+ "{}",
+ null,
+ SaveFileKind.Layout,
+ IsDirty: true,
+ RequiresSaveAsBeforeOverwrite: false))
+ .ToList();
+ var oversizedRecoverySaveRejected = false;
+ try
+ {
+ WorkspaceRecoveryStore.Save(oversizedRecoveryWorkspaces, 0, recoveryLimitRoot);
+ }
+ catch (ArgumentException)
+ {
+ oversizedRecoverySaveRejected = true;
+ }
+
+ tests.Check(
+ oversizedRecoverySaveRejected && !Directory.Exists(recoveryLimitRoot),
+ "Oversized recovery snapshots should be rejected before recovery files are written.");
+
+ Directory.CreateDirectory(recoveryLimitRoot);
+ var recoveryWorkspaceNodes = new JsonArray();
+ for (var index = 1; index <= WorkspaceSessionFileService.MaxWorkspaces + 1; index++)
+ recoveryWorkspaceNodes.Add(new JsonObject { ["Name"] = $"Workspace {index}" });
+
+ var oversizedRecoveryManifest = new JsonObject
+ {
+ ["Version"] = 1,
+ ["SavedUtc"] = DateTime.UtcNow,
+ ["ActiveWorkspaceIndex"] = 0,
+ ["Workspaces"] = recoveryWorkspaceNodes
+ };
+ File.WriteAllText(
+ Path.Combine(recoveryLimitRoot, "manifest.json"),
+ oversizedRecoveryManifest.ToJsonString());
+ tests.Check(
+ !WorkspaceRecoveryStore.TryLoad(out _, recoveryLimitRoot),
+ "Recovery loading should reject manifests beyond the shared maximum workspace count.");
+ }
+ finally
+ {
+ if (Directory.Exists(recoveryLimitRoot))
+ Directory.Delete(recoveryLimitRoot, recursive: true);
+ }
+
+ await AutoRecoverySmokeTests.RunAsync(tests);
+ }
+}
diff --git a/tests/ColumnPadStudio.SmokeTests/Program.cs b/tests/ColumnPadStudio.SmokeTests/Program.cs
index ef4f867..e942749 100644
--- a/tests/ColumnPadStudio.SmokeTests/Program.cs
+++ b/tests/ColumnPadStudio.SmokeTests/Program.cs
@@ -1,4 +1,5 @@
using ColumnPadStudio.Domain.Lists;
+using ColumnPadStudio.Domain.Workspaces;
using ColumnPadStudio.ViewModels;
using ColumnPadStudio.Services;
using ColumnPadStudio.Controls;
@@ -6,6 +7,8 @@
using System.IO;
using System.Net.Http;
using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
using System.Text.Json;
using System.Text.Json.Nodes;
using ColumnPadStudio.Models;
@@ -15,17 +18,30 @@
using ColumnPadStudio;
using ColumnPadStudio.SmokeTests;
-var failures = new List();
-var checks = 0;
+var tests = new SmokeTestContext();
-void Check(bool condition, string message)
-{
- checks++;
- if (!condition)
- failures.Add(message);
-}
+void Check(bool condition, string message) => tests.Check(condition, message);
var vm = new MainViewModel();
+var defaultWidthPreferences = new AppPreferences();
+
+Check(
+ !defaultWidthPreferences.FitColumnsToWindow
+ && defaultWidthPreferences.DefaultColumnWidthPx == AppPreferences.StandardColumnWidthPx
+ && defaultWidthPreferences.DefaultColumnWidthPx == 320,
+ "Column sizing should default to a fixed 320px strip, with Fit Columns to Window available only when selected.");
+
+Check(ColumnTextColorService.Normalize("blue") == ColumnTextColorService.Blue, "Text-colour presets should normalize case-insensitively.");
+Check(
+ ColumnTextColorService.TryNormalizeCustomHex("245a9a", out var normalizedTextColor)
+ && normalizedTextColor == "#245A9A",
+ "Custom text colours should normalize to a stable #RRGGBB value.");
+Check(
+ ColumnTextColorService.Normalize("not-a-colour") == ColumnTextColorService.ThemeDefault,
+ "Invalid text colours should fall back to the theme colour.");
+Check(
+ ColumnTextColorService.CreateCustomBrush("#245A9A")?.Color == Color.FromRgb(0x24, 0x5A, 0x9A),
+ "Custom text colours should create the expected editor brush.");
Check(vm.ThemePreset == "Default Mode", "Default theme should be 'Default Mode'.");
Check(vm.IsDefaultThemeSelected && !vm.IsLightThemeSelected && !vm.IsDarkThemeSelected, "Default theme menu state should match the default preset.");
@@ -36,79 +52,46 @@ void Check(bool condition, string message)
vm.EditorLanguages.Select(language => language.Tag).SequenceEqual(["en-US", "en-GB", "fr-FR", "de-DE", "es-ES", "it-IT", "pt-BR", "pt-PT", "nl-NL", "sv-SE", "da-DK", "nb-NO"]),
"Proofing language list should keep the current supported app range.");
Check(vm.Columns.Count == 3, "Default layout should start with 3 columns.");
+Check(vm.Columns.All(column => column.WidthPx is null), "New layouts should keep the normal display width implicit instead of saving redundant width values.");
+Check(
+ vm.GutterWidthPx == MainViewModel.MinimumGutterWidthPx
+ && vm.GutterWidthPx == MainViewModel.DefaultGutterWidthPx
+ && vm.Columns.All(column => column.LineNumberColumnWidth.IsAbsolute && Math.Abs(column.LineNumberColumnWidth.Value - MainViewModel.DefaultGutterWidthPx) < 0.001),
+ "New workspaces should start with the smallest shared gutter width.");
Check(vm.StatusText.Contains("Selected:"), "Status text should identify the selected column.");
Check(!vm.IsDirty, "New layout should start clean.");
+var paperSettingsVm = new MainViewModel();
+Check(paperSettingsVm.SelectedPaperStyle == PaperStyle.Ruled, "New workspaces should default to ruled paper.");
+Check(paperSettingsVm.IsPaperOffSelected, "Paper should start switched off.");
+Check(
+ Enum.GetValues().SequenceEqual([PaperStyle.Ruled, PaperStyle.SoftRuled, PaperStyle.StrongRuled]),
+ "Paper choices should be limited to aligned ruled-paper variants.");
+paperSettingsVm.UsePaperStyle(PaperStyle.SoftRuled);
+Check(paperSettingsVm.LinedPaperEnabled && paperSettingsVm.IsSoftRuledPaperSelected, "Choosing a ruled-paper variant should enable that style.");
+paperSettingsVm.SelectedPaperStyle = (PaperStyle)999;
+Check(paperSettingsVm.SelectedPaperStyle == PaperStyle.Ruled, "Unknown paper styles should fall back to ruled paper.");
+paperSettingsVm.LinedPaperEnabled = false;
+Check(paperSettingsVm.IsPaperOffSelected && !paperSettingsVm.IsRuledPaperSelected, "Switching paper off should clear the active style check.");
+vm.ActiveColumnId = vm.Columns[1].Id;
+Check(!vm.IsDirty, "Selecting another column should not mark otherwise unchanged content dirty.");
+vm.ActiveColumnId = vm.Columns[0].Id;
vm.Columns[0].Title = " Column\r\nOne\tName ";
Check(vm.Columns[0].Title == "Column One Name", "Column titles should be normalized to a clean single-line label.");
-var cleanedWorkspace = new WorkspaceSession(" Workspace\r\nAlpha\tDraft ", vm);
+var cleanedWorkspace = new WorkspaceSession(" Workspace\r\nAlpha\tDraft ", new MainViewModel());
Check(cleanedWorkspace.Name == "Workspace Alpha Draft", "Workspace names should be normalized to a clean single-line label.");
+Check(!cleanedWorkspace.HasSessionChanges, "A newly created workspace should begin with clean session metadata.");
+cleanedWorkspace.Name = "Workspace Renamed";
+Check(cleanedWorkspace.HasSessionChanges && cleanedWorkspace.IsDirty, "Renaming a workspace should participate in the workspace dirty state.");
+cleanedWorkspace.MarkSessionClean();
+Check(!cleanedWorkspace.HasSessionChanges, "Saving a workspace session should establish a clean metadata state.");
+cleanedWorkspace.LastMultiColumnCount = 5;
+Check(cleanedWorkspace.HasSessionChanges, "Changing the remembered multi-column mode should mark session metadata dirty.");
var cleanedWorkflow = new WorkflowDefinition { Name = " Workflow\r\nAlpha ", Category = " Research\tPlan " };
Check(cleanedWorkflow.Name == "Workflow Alpha" && cleanedWorkflow.Category == "Research Plan", "Workflow names and categories should be normalized to clean single-line labels.");
var cleanedWorkflowNode = new WorkflowDiagramNode { Kind = WorkflowNodeKind.Decision, Title = " Choose\r\nPath " };
Check(cleanedWorkflowNode.Title == "Choose Path", "Workflow node titles should be normalized to clean single-line labels.");
-Exception? resourceLoadException = null;
-Thread resourceLoadThread = new(() =>
-{
- try
- {
- _ = new Application();
- var resources = new ResourceDictionary
- {
- Source = new Uri("pack://application:,,,/ColumnPadStudio;component/Resources/AppResources.xaml", UriKind.Absolute)
- };
-
- Check(resources.MergedDictionaries.Count == 3, "App resources should stay split into theme brushes, control styles, and menu styles.");
- Check(resources["ControlPopupHighlightBrush"] is not null, "Theme brush resources should load from the app resource index.");
- Check(resources[typeof(MenuItem)] is Style, "Shared menu item style should load from the app resource index.");
- Check(resources["EmbeddedMenuPanelItemStyle"] is Style, "Embedded menu panel style should load from the app resource index.");
- Check(resources[typeof(Button)] is Style, "Shared button style should load from the app resource index.");
- Check(resources[typeof(TextBox)] is Style, "Shared textbox style should load from the app resource index.");
-
- Application.Current.Resources.MergedDictionaries.Add(resources);
-
- var styledButton = new Button { Content = "Template check" };
- styledButton.Style = (Style)resources[typeof(Button)];
- styledButton.ApplyTemplate();
- Check(styledButton.Template is not null, "Shared button style should apply without missing resource errors.");
-
- var styledTextBox = new TextBox { Text = "Template check" };
- styledTextBox.Style = (Style)resources[typeof(TextBox)];
- styledTextBox.ApplyTemplate();
- Check(styledTextBox.Template is not null, "Shared textbox style should apply without missing resource errors.");
-
- var workflowBuilderWindow = new WorkflowBuilderWindow();
- workflowBuilderWindow.ApplyTemplate();
- Check(workflowBuilderWindow.ViewModel is not null, "Workflow Builder window should initialize its view model.");
- Check(workflowBuilderWindow.Owner is null, "Workflow Builder should stay independent from the main window so minimizing ColumnPad does not minimize it.");
- Check(workflowBuilderWindow.ShowInTaskbar, "Workflow Builder should have its own taskbar entry.");
- Check(workflowBuilderWindow.WindowStartupLocation == WindowStartupLocation.CenterScreen, "Workflow Builder should open as an independent window, not as an owned child.");
- Check(workflowBuilderWindow.FindName("ExportWorkflowButton") is Button, "Workflow Builder should expose one grouped export action instead of separate export buttons.");
- workflowBuilderWindow.Close();
-
- var nestedMenu = new MenuItem { Header = "Column colour" };
- nestedMenu.Style = (Style)resources[typeof(MenuItem)];
- nestedMenu.Items.Add(new MenuItem { Header = "Blue" });
- nestedMenu.Items.Add(new MenuItem { Header = "Green" });
-
- var contextMenu = new ContextMenu();
- contextMenu.Items.Add(nestedMenu);
- contextMenu.ApplyTemplate();
- nestedMenu.ApplyTemplate();
- nestedMenu.IsSubmenuOpen = true;
- contextMenu.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
- Check(nestedMenu.Template is not null, "Nested context menu items should apply the shared app menu template.");
- nestedMenu.IsSubmenuOpen = false;
- }
- catch (Exception ex)
- {
- resourceLoadException = ex;
- }
-});
-resourceLoadThread.SetApartmentState(ApartmentState.STA);
-resourceLoadThread.Start();
-resourceLoadThread.Join();
-Check(resourceLoadException is null, $"App resource dictionaries should load without XAML errors: {resourceLoadException?.Message}");
+ThemeAndControlSmokeTests.Run(tests);
vm.SetColumnCount(0);
Check(vm.Columns.Count == 1, "SetColumnCount should clamp to a minimum of 1 column.");
@@ -140,6 +123,7 @@ void Check(bool condition, string message)
Check(vm.StatusText.Contains("first column"), "MoveActiveColumnLeft should explain when the selected column is already first.");
vm.Columns[0].LineMarkerMode = LineMarkerMode.Checklist;
vm.Columns[0].SetCheckedChecklistLineIndexes([0]);
+vm.Columns[0].EditorTextColor = ColumnTextColorService.Blue;
vm.Columns[0].Images.Add(new ColumnImageViewModel(
"C:\\images\\diagram.png",
"diagram.png",
@@ -156,6 +140,7 @@ void Check(bool condition, string message)
Check(Math.Abs(vm.Columns[^1].Images[0].Width - 420) < 0.001, "DuplicateActive should preserve duplicated image display width.");
Check(Math.Abs(vm.Columns[^1].Images[0].Left - 18) < 0.001 && Math.Abs(vm.Columns[^1].Images[0].Top - 26) < 0.001, "DuplicateActive should preserve image position.");
Check(vm.Columns[^1].Images[0].Layer == ColumnImageLayer.BehindText, "DuplicateActive should preserve image text layer.");
+Check(vm.Columns[^1].EditorTextColor == ColumnTextColorService.Blue, "DuplicateActive should preserve the column text colour.");
vm.ThemePreset = "High Contrast";
Check(vm.ThemePreset == "Dark Mode", "Legacy theme 'High Contrast' should normalize to 'Dark Mode'.");
@@ -174,14 +159,22 @@ void Check(bool condition, string message)
vm.Columns[0].EditorFontStyle = FontStyles.Italic;
vm.Columns[0].EditorFontWeight = FontWeights.Bold;
vm.Columns[0].UseDefaultFont = false;
+vm.Columns[0].EditorTextColor = "#2A6F97";
vm.SpellCheckEnabled = false;
vm.EditorLanguageTag = "fr-FR";
Check(vm.ProofingLanguageDisplayName.Contains("French", StringComparison.OrdinalIgnoreCase), "Proofing display name should describe the selected language.");
Check(vm.StatusText.Contains("Proofing language:", StringComparison.Ordinal), "Changing proofing language should explain what changed.");
+vm.GutterWidthPx = 36;
+vm.UsePaperStyle(PaperStyle.StrongRuled);
vm.ActiveColumnId = vm.Columns[1].Id;
Check(vm.IsDirty, "Changing the layout should mark the workspace dirty.");
var json = vm.ToLayoutJson();
+var savedLayoutRoot = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse saved layout JSON.");
+Check(savedLayoutRoot["FileType"]?.GetValue() == "ColumnPadLayout", "Saved layouts should include an explicit ColumnPad file type.");
+Check(savedLayoutRoot["Version"]?.GetValue() == 19, "Saved layouts should use the shared-gutter layout schema version.");
+Check(savedLayoutRoot["PaperStyle"]?.GetValue() == "StrongRuled", "Saved layouts should store the selected ruled-paper style.");
+Check(savedLayoutRoot["GutterWidthPx"]?.GetValue() == 36, "Saved layouts should store the shared gutter width once per workspace.");
var loaded = new MainViewModel();
loaded.LoadFromJson(json, "smoke");
@@ -202,12 +195,64 @@ void Check(bool condition, string message)
Check(Math.Abs(loaded.Columns[0].EditorFontSize - 17) < 0.001, "JSON round-trip should preserve per-column font size.");
Check(loaded.Columns[0].EditorFontStyle == FontStyles.Italic, "JSON round-trip should preserve per-column font style.");
Check(loaded.Columns[0].EditorFontWeight == FontWeights.Bold, "JSON round-trip should preserve per-column font weight.");
+Check(loaded.Columns[0].EditorTextColor == "#2A6F97", "JSON round-trip should preserve a custom column text colour.");
+Check(loaded.Columns[0].HasCustomEditorTextColor, "A loaded custom text colour should restore its editor brush.");
Check(loaded.ActiveColumnId == loaded.Columns[1].Id, "JSON round-trip should restore the active column.");
Check(!loaded.SpellCheckEnabled, "JSON round-trip should preserve spellcheck setting.");
Check(loaded.EditorLanguageTag == "fr-FR", "JSON round-trip should preserve editor language setting.");
+Check(loaded.LinedPaperEnabled && loaded.SelectedPaperStyle == PaperStyle.StrongRuled, "JSON round-trip should preserve the enabled ruled-paper style.");
+Check(loaded.GutterWidthPx == 36 && loaded.Columns.All(column => Math.Abs(column.LineNumberColumnWidth.Value - 36) < 0.001), "JSON round-trip should restore one shared gutter width for every column.");
Check(loaded.GetActive()?.Title == vm.Columns[1].Title, "Restored active column should match the saved column.");
Check(!loaded.IsDirty, "Loaded layout should start clean.");
+var legacyFontNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse legacy font layout.");
+legacyFontNode["Version"] = 13;
+legacyFontNode["EditorFontFamily"] = "Consolas";
+legacyFontNode["EditorFontStyle"] = "Bold Italic";
+var legacyFontColumns = legacyFontNode["Columns"]?.AsArray() ?? throw new InvalidOperationException("Could not find legacy font columns.");
+var legacyFontFirstColumn = legacyFontColumns[0]?.AsObject() ?? throw new InvalidOperationException("Could not find the first legacy font column.");
+legacyFontFirstColumn.Remove("FontStyle");
+legacyFontFirstColumn.Remove("FontWeight");
+var legacyFontLoaded = new MainViewModel
+{
+ EditorFontFamily = "Consolas",
+ EditorFontStyleName = "Regular"
+};
+Check(legacyFontLoaded.LoadFromJson(legacyFontNode.ToJsonString(), "legacy-font"), "Older layouts without per-column font faces should still load.");
+Check(legacyFontLoaded.EditorFontStyleName == "Bold Italic", "A loaded layout should apply its saved global font face.");
+Check(
+ legacyFontLoaded.Columns[0].EditorFontStyle == FontStyles.Italic
+ && legacyFontLoaded.Columns[0].EditorFontWeight == FontWeights.Bold,
+ "A column without saved font-face fields should inherit the global font style and weight from that layout, not the pre-load app state.");
+
+var undefinedEnumNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse undefined-enum layout.");
+var undefinedEnumColumns = undefinedEnumNode["Columns"]?.AsArray() ?? throw new InvalidOperationException("Could not find undefined-enum columns.");
+var undefinedEnumFirstColumn = undefinedEnumColumns[0]?.AsObject() ?? throw new InvalidOperationException("Could not find the first undefined-enum column.");
+var undefinedEnumImages = undefinedEnumFirstColumn["Images"]?.AsArray() ?? throw new InvalidOperationException("Could not find undefined-enum images.");
+var undefinedEnumFirstImage = undefinedEnumImages[0]?.AsObject() ?? throw new InvalidOperationException("Could not find the first undefined-enum image.");
+undefinedEnumFirstColumn["PastePreset"] = "999";
+undefinedEnumFirstColumn["LineMarkerMode"] = "999";
+undefinedEnumFirstImage["Layer"] = "999";
+var undefinedEnumLoaded = new MainViewModel();
+Check(undefinedEnumLoaded.LoadFromJson(undefinedEnumNode.ToJsonString(), "undefined-enums"), "Undefined numeric enum values should not invalidate an otherwise healthy layout.");
+Check(undefinedEnumLoaded.Columns[0].PastePreset == PasteListPreset.None, "An undefined numeric paste preset should safely fall back to none.");
+Check(undefinedEnumLoaded.Columns[0].LineMarkerMode == LineMarkerMode.Numbers, "An undefined numeric line-marker mode should safely fall back to numbers.");
+Check(undefinedEnumLoaded.Columns[0].Images[0].Layer == ColumnImageLayer.InFrontOfText, "An undefined numeric picture layer should safely fall back in front of text.");
+
+var legacyEnumNamesNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse legacy enum-name layout.");
+var legacyEnumNameColumns = legacyEnumNamesNode["Columns"]?.AsArray() ?? throw new InvalidOperationException("Could not find legacy enum-name columns.");
+var legacyEnumNameFirstColumn = legacyEnumNameColumns[0]?.AsObject() ?? throw new InvalidOperationException("Could not find the first legacy enum-name column.");
+var legacyEnumNameImages = legacyEnumNameFirstColumn["Images"]?.AsArray() ?? throw new InvalidOperationException("Could not find legacy enum-name images.");
+var legacyEnumNameFirstImage = legacyEnumNameImages[0]?.AsObject() ?? throw new InvalidOperationException("Could not find the first legacy enum-name image.");
+legacyEnumNameFirstColumn["PastePreset"] = "checklist";
+legacyEnumNameFirstColumn["LineMarkerMode"] = "bullets";
+legacyEnumNameFirstImage["Layer"] = "behindtext";
+var legacyEnumNamesLoaded = new MainViewModel();
+Check(legacyEnumNamesLoaded.LoadFromJson(legacyEnumNamesNode.ToJsonString(), "legacy-enum-names"), "Valid legacy enum names should remain loadable case-insensitively.");
+Check(legacyEnumNamesLoaded.Columns[0].PastePreset == PasteListPreset.Checklist, "The legacy checklist paste-preset name should remain supported.");
+Check(legacyEnumNamesLoaded.Columns[0].LineMarkerMode == LineMarkerMode.Bullets, "The legacy bullets line-marker name should remain supported.");
+Check(legacyEnumNamesLoaded.Columns[0].Images[0].Layer == ColumnImageLayer.BehindText, "The legacy behind-text picture-layer name should remain supported.");
+
var preserveTheme = new MainViewModel();
preserveTheme.ThemePreset = "Dark Mode";
preserveTheme.LoadFromJson(json, "smoke", preserveCurrentTheme: true);
@@ -222,230 +267,24 @@ void Check(bool condition, string message)
"Recovery snapshots should still load when preserving the current theme.");
Check(recoveredThemeVm.ThemePreset == "Dark Mode", "Recovery restore should preserve the current app theme.");
-var preferencesPath = Path.Combine(Path.GetTempPath(), $"columnpad-preferences-{Guid.NewGuid():N}.json");
-AppPreferencesService.Save(new AppPreferences("Dark Mode"), preferencesPath);
-var loadedPreferences = AppPreferencesService.Load(preferencesPath);
-Check(loadedPreferences.ThemePreset == "Dark Mode", "Saved app preferences should round-trip the selected theme.");
-File.WriteAllText(preferencesPath, "{not valid json");
-Check(AppPreferencesService.Load(preferencesPath).ThemePreset == "Default Mode", "Invalid app preferences should fall back to the default theme.");
-File.Delete(preferencesPath);
-
-Check(
- AppStoragePaths.CrashLogsDirectory == Path.Combine(AppStoragePaths.RootDirectory, "CrashLogs"),
- "App storage paths should expose the crash-log directory as a single source of truth.");
-
-Check(
- typeof(MainWindow).Assembly.GetName().Name == "ColumnPadStudio",
- "The application assembly should publish with the stable ColumnPadStudio executable name.");
-
-const string latestReleaseJson = """
- {
- "tag_name": "v2.4.0",
- "html_url": "https://github.com/example-owner/ColumnPadStudio/releases/tag/v2.4.0"
- }
- """;
-using (var updateHttpClient = new HttpClient(new StaticJsonResponseHandler(latestReleaseJson)))
-{
- var updateService = new GitHubReleaseUpdateService(updateHttpClient);
- var latestRelease = await updateService.GetLatestReleaseAsync();
-
- Check(latestRelease?.Version == new Version(2, 4, 0, 0), "GitHub update checks should parse release tags into comparable versions.");
- Check(latestRelease?.DisplayVersion == "v2.4.0", "GitHub update checks should keep a clean version label for the notification.");
- Check(latestRelease?.ReleasePage.AbsoluteUri == "https://github.com/example-owner/ColumnPadStudio/releases/tag/v2.4.0", "GitHub update checks should preserve the official HTTPS release page.");
- Check(
- latestRelease is not null && GitHubReleaseUpdateService.IsNewerRelease(latestRelease.Version, new Version(2, 3, 0, 0)),
- "GitHub update checks should detect a newer stable release.");
- Check(
- latestRelease is not null && !GitHubReleaseUpdateService.IsNewerRelease(latestRelease.Version, new Version(2, 4, 0, 0)),
- "GitHub update checks should not notify for the installed release.");
-}
-
-const string untrustedReleasePageJson = """
- {
- "tag_name": "v2.4.0",
- "html_url": "https://example.com/not-columnpad"
- }
- """;
-using (var updateHttpClient = new HttpClient(new StaticJsonResponseHandler(untrustedReleasePageJson)))
-{
- var updateService = new GitHubReleaseUpdateService(updateHttpClient);
- var latestRelease = await updateService.GetLatestReleaseAsync();
- Check(
- latestRelease?.ReleasePage == GitHubReleaseUpdateService.ReleasesPageUri,
- "Update links should fall back to the trusted ColumnPadStudio GitHub releases page.");
-}
-
-using (var updateHttpClient = new HttpClient(
- new StaticJsonResponseHandler("{}", System.Net.HttpStatusCode.NotFound)))
-{
- var updateService = new GitHubReleaseUpdateService(updateHttpClient);
- Check(
- await updateService.GetLatestReleaseAsync() is null,
- "Update checks should quietly handle a repository with no published release.");
-}
-
-Check(
- GitHubReleaseUpdateService.TryParseReleaseVersion("v2.5.0-beta.1", out var parsedReleaseVersion) &&
- parsedReleaseVersion == new Version(2, 5, 0, 0),
- "Release version parsing should ignore semantic-version labels when comparing versions.");
-Check(
- !GitHubReleaseUpdateService.TryParseReleaseVersion("latest", out _),
- "Release version parsing should reject tags that do not contain a numeric version.");
-
-var atomicRoot = Path.Combine(Path.GetTempPath(), $"columnpad-atomic-{Guid.NewGuid():N}");
-try
-{
- var atomicPath = Path.Combine(atomicRoot, "nested", "note.txt");
- AtomicFileWriter.WriteText(atomicPath, "first");
- Check(File.ReadAllText(atomicPath) == "first", "Atomic writer should create missing target directories.");
- AtomicFileWriter.WriteText(atomicPath, "second");
- Check(File.ReadAllText(atomicPath) == "second", "Atomic writer should replace existing files cleanly.");
- Check(Directory.GetFiles(Path.GetDirectoryName(atomicPath)!, "*.tmp").Length == 0, "Atomic writer should clean up temporary files after a successful write.");
-}
-finally
-{
- if (Directory.Exists(atomicRoot))
- Directory.Delete(atomicRoot, recursive: true);
-}
-
-var workflowTemp = Path.Combine(Path.GetTempPath(), $"columnpad-workflows-{Guid.NewGuid():N}");
-var workflowService = new WorkflowService(workflowTemp);
-var emptyWorkflowVm = new WorkflowBuilderViewModel(workflowService);
-emptyWorkflowVm.Load();
-Check(emptyWorkflowVm.Workflows.Count == 1, "Workflow Builder should create one workflow when no saved workflows exist.");
-Check(WorkflowTemplateCatalog.Templates.Count >= 10, "Workflow starter catalog should provide multiple practical starters.");
-var workflowTemplateIds = WorkflowTemplateCatalog.Templates.Select(template => template.Id).ToList();
-Check(workflowTemplateIds.Count == workflowTemplateIds.Distinct(StringComparer.OrdinalIgnoreCase).Count(), "Workflow starter catalog should not contain duplicate IDs.");
-Check(WorkflowTemplateCatalog.Templates.All(template => template.Nodes.Count > 0), "Workflow starter catalog should not contain empty starter diagrams.");
-Check(WorkflowTemplateCatalog.Templates.All(template => template.Connections.Count > 0), "Workflow starter catalog should wire starter nodes together.");
-var essayStarter = WorkflowTemplateCatalog.Templates.FirstOrDefault(template => template.Id == "essay-plan");
-Check(essayStarter is not null, "Workflow starter catalog should include an essay planning starter.");
-if (essayStarter is not null)
-{
- var essayWorkflow = essayStarter.CreateWorkflowInstance("Essay Plan Copy");
- Check(essayWorkflow.Name == "Essay Plan Copy", "Workflow starter instances should allow a custom workflow name.");
- Check(essayWorkflow.Nodes.Count >= 5, "Workflow starter instances should create a useful editable diagram.");
- Check(essayWorkflow.Links.Count > 0, "Workflow starter instances should create connections between starter nodes.");
- var thesisNode = essayWorkflow.Nodes.FirstOrDefault(node => node.Title == "Define thesis");
- Check(!string.IsNullOrWhiteSpace(thesisNode?.Goal), "Workflow starter nodes should include a real goal, not just a box title.");
- Check(thesisNode?.ChecklistItems.Count >= 2, "Workflow starter nodes should include useful checklist data.");
-}
+await InfrastructureSmokeTests.RunAsync(tests);
-var workflowBuilderVm = new WorkflowBuilderViewModel(workflowService);
-workflowBuilderVm.AddWorkflow();
-workflowBuilderVm.AddNode(WorkflowNodeKind.Decision);
-Check(workflowBuilderVm.SelectedNode?.Kind == WorkflowNodeKind.Decision, "Workflow builder palette should add the requested node kind.");
-workflowBuilderVm.SelectedNode!.X = 1260;
-workflowBuilderVm.SelectedNode.Width = 220;
-Check(workflowBuilderVm.DiagramCanvasWidth >= 1576, "Workflow builder canvas should expand to include far-right nodes.");
-workflowBuilderVm.SelectedNode.Y = 780;
-workflowBuilderVm.SelectedNode.Height = 120;
-Check(workflowBuilderVm.DiagramCanvasHeight >= 996, "Workflow builder canvas should expand to include lower nodes.");
-
-var workflowDefinition = new WorkflowDefinition { Name = "Colour test" };
-workflowDefinition.Id = " workflow id with spaces ";
-Check(workflowDefinition.Id == "workflow id with spaces", "Workflow IDs should trim outer whitespace without applying display-label cleanup.");
-workflowDefinition.Nodes.Add(new WorkflowDiagramNode
-{
- Id = " start ",
- Kind = WorkflowNodeKind.Start,
- Title = "Start",
- Color = WorkflowNodeColor.Rose,
- Goal = "Round-trip goal",
- Instructions = "Round-trip instructions",
- ExpectedOutput = "Round-trip output",
- ChecklistItems = new ObservableCollection
- {
- new() { Text = "First check" },
- new() { Text = "Done check", IsDone = true }
- }
-});
-workflowDefinition.Nodes.Add(new WorkflowDiagramNode { Id = "end", Kind = WorkflowNodeKind.End, Title = "End", Color = WorkflowNodeColor.Green });
-Check(workflowDefinition.Nodes[0].Id == "start", "Workflow node IDs should use identity cleanup, not display-label cleanup.");
-workflowDefinition.Links.Add(new WorkflowDiagramLink { FromNodeId = "start", ToNodeId = "end" });
-workflowService.Save(workflowDefinition);
-Check(!string.IsNullOrWhiteSpace(workflowDefinition.FilePath), "Workflow save should assign a file path.");
-Check(workflowService.TryLoad(workflowDefinition.FilePath!, out var loadedWorkflow), "Workflow service should reload saved workflow JSON.");
-Check(loadedWorkflow.SchemaVersion >= 3, "Workflow service should normalize saved workflows to the current schema.");
-Check(loadedWorkflow.Nodes[0].Color == WorkflowNodeColor.Rose, "Workflow node colour should persist through JSON save/load.");
-Check(loadedWorkflow.Nodes[0].Goal == "Round-trip goal", "Workflow node goal should persist through JSON save/load.");
-Check(loadedWorkflow.Nodes[0].Instructions == "Round-trip instructions", "Workflow node instructions should persist through JSON save/load.");
-Check(loadedWorkflow.Nodes[0].ExpectedOutput == "Round-trip output", "Workflow node expected output should persist through JSON save/load.");
-Check(loadedWorkflow.Nodes[0].ChecklistItems.Count == 2 && loadedWorkflow.Nodes[0].ChecklistItems[1].IsDone, "Workflow node checklist data should persist through JSON save/load.");
-var readableWorkflowText = workflowService.BuildTextExport(workflowDefinition);
-Check(readableWorkflowText.StartsWith(WorkflowService.TextExportMarker), "Workflow text export should include a clear ColumnPad marker.");
-Check(readableWorkflowText.Contains("Workflow: Colour test"), "Workflow text export should include the workflow name.");
-Check(readableWorkflowText.Contains("1. [Start] Start"), "Workflow text export should list readable node steps.");
-Check(readableWorkflowText.Contains("Round-trip goal"), "Workflow text export should include node goals.");
-Check(readableWorkflowText.Contains("- [x] Done check"), "Workflow text export should include checklist completion state.");
-Check(readableWorkflowText.Contains("1. Start -> 2. End"), "Workflow text export should show connections using node names.");
-var readableWorkflowMarkdown = workflowService.BuildMarkdownExport(workflowDefinition);
-Check(readableWorkflowMarkdown.StartsWith(WorkflowService.MarkdownExportMarker), "Workflow markdown export should include a clear ColumnPad marker.");
-Check(readableWorkflowMarkdown.Contains("# Colour test"), "Workflow markdown export should include the workflow name as a heading.");
-Check(readableWorkflowMarkdown.Contains("### 1. Start: Start"), "Workflow markdown export should list readable node steps.");
-Check(readableWorkflowMarkdown.Contains("Round-trip instructions"), "Workflow markdown export should include node instructions.");
-Check(readableWorkflowMarkdown.Contains("- [x] Done check"), "Workflow markdown export should include checklist completion state.");
-var readableWorkflowTextPath = Path.Combine(workflowTemp, "colour-test.workflow.txt");
-workflowService.ExportTextToPath(workflowDefinition, readableWorkflowTextPath);
-Check(File.Exists(readableWorkflowTextPath), "Workflow text export should write a text file.");
-var readableWorkflowMarkdownPath = Path.Combine(workflowTemp, "colour-test.workflow.md");
-workflowService.ExportMarkdownToPath(workflowDefinition, readableWorkflowMarkdownPath);
-Check(File.Exists(readableWorkflowMarkdownPath), "Workflow markdown export should write a markdown file.");
-var existingWorkflowVm = new WorkflowBuilderViewModel(workflowService);
-existingWorkflowVm.Load();
-var workflowCountBeforeAdd = existingWorkflowVm.Workflows.Count;
-existingWorkflowVm.AddWorkflow();
-Check(existingWorkflowVm.Workflows.Count == workflowCountBeforeAdd + 1, "Workflow Builder Add Workflow should add one workflow.");
-
-Check(!WorkflowService.IsWorkflowDefinitionJson("{}"), "Workflow detection should reject unrelated empty JSON objects.");
-Check(!WorkflowService.IsWorkflowDefinitionJson(json), "Workflow detection should reject ColumnPad layout JSON.");
-var camelCaseWorkflowPath = Path.Combine(workflowTemp, "camel-case.workflow.json");
-File.WriteAllText(camelCaseWorkflowPath, """
-{
- "fileType": "ColumnPadWorkflow",
- "schemaVersion": 3,
- "id": "camel-case",
- "name": "Camel Case Workflow",
- "nodes": [
- { "id": "start", "kind": "Start", "title": "Start" }
- ],
- "links": []
-}
-""");
-Check(workflowService.TryLoad(camelCaseWorkflowPath, out var camelCaseWorkflow), "Workflow import should accept case-insensitive property names and readable enum names.");
-Check(camelCaseWorkflow.Nodes.Count == 1 && camelCaseWorkflow.Nodes[0].Kind == WorkflowNodeKind.Start, "Case-insensitive workflow import should preserve node data.");
-
-var invalidWorkflowPath = Path.Combine(workflowTemp, "invalid.workflow.json");
-File.WriteAllText(invalidWorkflowPath, "{}");
-_ = workflowService.LoadAll();
-Check(workflowService.LastLoadWarnings.Contains("invalid.workflow.json"), "Workflow library loading should report unreadable workflow filenames instead of silently skipping them.");
-
-var dirtyWorkflowService = new WorkflowService(Path.Combine(workflowTemp, "dirty-state"));
-var dirtyWorkflowVm = new WorkflowBuilderViewModel(dirtyWorkflowService);
-dirtyWorkflowVm.Load();
-Check(!dirtyWorkflowVm.HasUnsavedChanges, "Opening an empty Workflow Builder should not treat its untouched blank draft as a user edit.");
-dirtyWorkflowVm.SelectedWorkflow!.Name = "My Workflow";
-Check(dirtyWorkflowVm.HasUnsavedChanges, "Editing the blank workflow draft should mark it unsaved.");
-dirtyWorkflowVm.SaveSelectedWorkflow();
-Check(!dirtyWorkflowVm.HasUnsavedChanges, "Saving a workflow should establish a clean state.");
-dirtyWorkflowVm.SelectedWorkflow!.Description = "Changed after save";
-Check(dirtyWorkflowVm.HasUnsavedChanges, "Editing workflow details should mark the Workflow Builder dirty.");
-Check(dirtyWorkflowVm.SaveAllChangedWorkflows() == 1, "Save-all should save each changed workflow once.");
-Check(!dirtyWorkflowVm.HasUnsavedChanges, "Save-all should clear the Workflow Builder dirty state.");
-Directory.Delete(workflowTemp, recursive: true);
+var workflowDefinition = WorkflowSmokeTests.Run(tests, json);
var legacyNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse round-trip JSON for legacy normalization test.");
legacyNode["Version"] = 11;
var legacyColumns = legacyNode["Columns"]?.AsArray() ?? throw new InvalidOperationException("Could not find columns array for legacy normalization test.");
var legacyFirstColumn = legacyColumns[0]?.AsObject() ?? throw new InvalidOperationException("Could not find first column for legacy normalization test.");
+legacyFirstColumn.Remove("EditorTextColor");
legacyFirstColumn["Text"] = "line one\\r\\nline two\\nline three";
var legacyLoaded = new MainViewModel();
Check(legacyLoaded.LoadFromJson(legacyNode.ToJsonString(), "legacy"), "Legacy-escaped layout JSON should still load.");
Check(legacyLoaded.Columns[0].Text == "line one\nline two\nline three", "Legacy-escaped newline sequences should be decoded into real line breaks during load.");
+Check(legacyLoaded.Columns[0].EditorTextColor == ColumnTextColorService.ThemeDefault, "Older layouts without colour data should use the theme text colour.");
legacyFirstColumn["Text"] = "pitch idea -> break it down -> lock it -> structure tree -> .sln -> build in sections";
Check(legacyLoaded.LoadFromJson(legacyNode.ToJsonString(), "legacy"), "Legacy inline layout JSON should still load.");
-Check(legacyLoaded.Columns[0].Text.Contains("\n"), "Legacy inline text should be migrated into hard line breaks during load.");
+Check(legacyLoaded.Columns[0].Text == "pitch idea -> break it down -> lock it -> structure tree -> .sln -> build in sections", "Legacy inline text should remain byte-for-byte intact during load.");
legacyFirstColumn["Text"] = "- [ ] first task\n- [x] done task";
legacyFirstColumn["LineMarkerMode"] = null;
legacyFirstColumn["CheckedChecklistLineIndexes"] = null;
@@ -453,6 +292,62 @@ await updateService.GetLatestReleaseAsync() is null,
Check(legacyLoaded.Columns[0].LineMarkerMode == LineMarkerMode.Checklist, "Legacy checklist-marker text should migrate to checklist gutter mode.");
Check(legacyLoaded.Columns[0].Text == "first task\ndone task", "Legacy checklist-marker text should decode to clean plain text.");
Check(legacyLoaded.Columns[0].IsChecklistLineChecked(1), "Legacy checklist-marker migration should restore checked rows in gutter metadata.");
+var versionFourteenNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse version-14 compatibility layout.");
+versionFourteenNode["Version"] = 14;
+var versionFourteenColumns = versionFourteenNode["Columns"]?.AsArray() ?? throw new InvalidOperationException("Could not find version-14 columns.");
+var versionFourteenFirstColumn = versionFourteenColumns[0]?.AsObject() ?? throw new InvalidOperationException("Could not find the version-14 first column.");
+var validSingleLineText = string.Join(' ', Enumerable.Repeat("structured", 12));
+versionFourteenFirstColumn["Text"] = validSingleLineText;
+versionFourteenFirstColumn.Remove("EditorTextColor");
+var versionFourteenLoaded = new MainViewModel();
+Check(versionFourteenLoaded.LoadFromJson(versionFourteenNode.ToJsonString(), "version-14"), "Version-14 layouts should remain loadable after adding text colour.");
+Check(versionFourteenLoaded.Columns[0].Text == validSingleLineText, "Version-14 text should not be passed through older inline-text migration again.");
+Check(versionFourteenLoaded.Columns[0].EditorTextColor == ColumnTextColorService.ThemeDefault, "Version-14 layouts should gain the theme text colour.");
+var currentLiteralEscapeNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse current layout for literal-escape preservation.");
+var currentLiteralEscapeColumns = currentLiteralEscapeNode["Columns"]?.AsArray() ?? throw new InvalidOperationException("Could not find current layout columns.");
+currentLiteralEscapeColumns[0]!.AsObject()["Text"] = @"regex \r\n and code \n stay literal";
+var currentLiteralEscapeLoaded = new MainViewModel();
+Check(currentLiteralEscapeLoaded.LoadFromJson(currentLiteralEscapeNode.ToJsonString(), "current-literal"), "Current layouts containing literal escape text should load.");
+Check(currentLiteralEscapeLoaded.Columns[0].Text == @"regex \r\n and code \n stay literal", "Current layout text should never decode literal backslash escape sequences.");
+var futureLayoutNode = JsonNode.Parse(json)!.AsObject();
+futureLayoutNode["Version"] = 999;
+var futureLayoutTarget = new MainViewModel();
+var futureLayoutBefore = futureLayoutTarget.ToLayoutJson();
+Check(!futureLayoutTarget.LoadFromJson(futureLayoutNode.ToJsonString(), "future"), "Layouts from unsupported future schema versions should be rejected.");
+Check(futureLayoutTarget.ToLayoutJson() == futureLayoutBefore, "Rejecting a future layout should not alter the current workspace.");
+var versionFifteenNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse version-15 compatibility layout.");
+versionFifteenNode["Version"] = 15;
+versionFifteenNode.Remove("PaperStyle");
+var versionFifteenLoaded = new MainViewModel();
+Check(versionFifteenLoaded.LoadFromJson(versionFifteenNode.ToJsonString(), "version-15"), "Version-15 layouts should remain loadable after adding paper styles.");
+Check(versionFifteenLoaded.LinedPaperEnabled && versionFifteenLoaded.SelectedPaperStyle == PaperStyle.Ruled, "Older lined-paper layouts should open as ruled paper.");
+var versionEighteenNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse version-18 compatibility layout.");
+versionEighteenNode["Version"] = 18;
+versionEighteenNode.Remove("GutterWidthPx");
+var versionEighteenLoaded = new MainViewModel();
+Check(versionEighteenLoaded.LoadFromJson(versionEighteenNode.ToJsonString(), "version-18"), "Version-18 layouts should remain loadable after adding a shared gutter width.");
+Check(versionEighteenLoaded.GutterWidthPx == MainViewModel.MinimumGutterWidthPx, "Layouts without a saved gutter width should use the smallest default.");
+var legacyGutterNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse legacy shared-gutter layout.");
+legacyGutterNode["Version"] = 18;
+legacyGutterNode["GutterWidthPx"] = 64;
+var legacyGutterLoaded = new MainViewModel();
+Check(legacyGutterLoaded.LoadFromJson(legacyGutterNode.ToJsonString(), "legacy-gutter"), "Layouts with an earlier saved gutter width should still load.");
+Check(legacyGutterLoaded.GutterWidthPx == 64 && legacyGutterLoaded.Columns.All(column => Math.Abs(column.LineNumberColumnWidth.Value - 64) < 0.001), "A saved shared gutter width should restore across all columns.");
+var invalidPaperStyleNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse invalid paper-style layout.");
+invalidPaperStyleNode["PaperStyle"] = "Unknown";
+var invalidPaperStyleLoaded = new MainViewModel();
+Check(invalidPaperStyleLoaded.LoadFromJson(invalidPaperStyleNode.ToJsonString(), "invalid-paper-style"), "An unknown paper style should not invalidate an otherwise healthy layout.");
+Check(invalidPaperStyleLoaded.SelectedPaperStyle == PaperStyle.Ruled, "An unknown saved paper style should safely fall back to ruled paper.");
+foreach (var retiredPaperStyle in new[] { "Grid", "Dots" })
+{
+ var retiredPaperStyleNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException($"Could not parse the retired {retiredPaperStyle} paper-style layout.");
+ retiredPaperStyleNode["PaperStyle"] = retiredPaperStyle;
+ var retiredPaperStyleLoaded = new MainViewModel();
+ Check(
+ retiredPaperStyleLoaded.LoadFromJson(retiredPaperStyleNode.ToJsonString(), $"retired-{retiredPaperStyle}-paper-style")
+ && retiredPaperStyleLoaded.SelectedPaperStyle == PaperStyle.Ruled,
+ $"Saved {retiredPaperStyle} paper should open as the original ruled paper.");
+}
var rawDocument = new MainViewModel();
rawDocument.LoadTextDocument("alpha\n beta", "notes.txt", "C:\\temp\\notes.txt", SaveFileKind.TextDocument);
Check(rawDocument.Columns.Count == 1, "Raw text open should create a single column.");
@@ -465,6 +360,30 @@ await updateService.GetLatestReleaseAsync() is null,
Check(string.IsNullOrWhiteSpace(rawDocument.CurrentFilePath), "Promoting a raw text document should detach it from the original file path.");
Check(!rawDocument.RequiresSaveAsBeforeOverwrite, "Promoted layouts should no longer require Save As once detached.");
+var styledRawDocument = new MainViewModel();
+styledRawDocument.LoadTextDocument("styled text", "styled.txt", "C:\\temp\\styled.txt", SaveFileKind.TextDocument);
+styledRawDocument.PrepareForRichContent();
+styledRawDocument.Columns[0].EditorTextColor = ColumnTextColorService.Red;
+Check(styledRawDocument.CurrentFileKind == SaveFileKind.Layout, "Applying column formatting should promote a raw text document to a layout.");
+Check(string.IsNullOrWhiteSpace(styledRawDocument.CurrentFilePath), "Promoting formatted text should detach it from the original raw file.");
+
+var resizedRawDocument = new MainViewModel();
+resizedRawDocument.LoadTextDocument("width-sensitive text", "width.txt", "C:\\temp\\width.txt", SaveFileKind.TextDocument);
+resizedRawDocument.Columns[0].WidthPx = 420;
+Check(resizedRawDocument.CurrentFileKind == SaveFileKind.Layout && resizedRawDocument.CurrentFilePath is null, "Changing persistent per-column layout data should promote a raw document before it can be lost.");
+
+var gutterRawDocument = new MainViewModel();
+gutterRawDocument.LoadTextDocument("gutter-sensitive text", "gutter.txt", "C:\\temp\\gutter.txt", SaveFileKind.TextDocument);
+gutterRawDocument.GutterWidthPx = 64;
+Check(gutterRawDocument.CurrentFileKind == SaveFileKind.Layout && gutterRawDocument.CurrentFilePath is null, "Changing the gutter width should promote a raw document before its layout setting can be lost.");
+
+var styledExport = new MainViewModel();
+styledExport.LoadFromExportText("ColumnPad Export\nFormat: Text\n\n===== Notes =====\n\ntext\n", "notes.txt", "C:\\temp\\notes.txt");
+styledExport.PrepareForRichContent();
+styledExport.Columns[0].EditorTextColor = ColumnTextColorService.Blue;
+Check(styledExport.CurrentFileKind == SaveFileKind.Layout, "Applying rich formatting should promote a text export to a native layout.");
+Check(string.IsNullOrWhiteSpace(styledExport.CurrentFilePath), "Promoting a rich text export should detach it from the lossy export path.");
+
var nativeLayoutPath = Path.Combine(Path.GetTempPath(), $"columnpad-native-{Guid.NewGuid():N}.columnpad.json");
try
{
@@ -499,7 +418,6 @@ await updateService.GetLatestReleaseAsync() is null,
var carriageReturnMetrics = new ColumnViewModel { Text = "first\rsecond\r\nthird\nfourth" };
Check(carriageReturnMetrics.LineCount == 4, "Column metrics should count LF, CRLF, and standalone CR line breaks consistently.");
-Check(ClipboardTextService.CountLineBreaks("first\rsecond\r\nthird\nfourth") == 3, "Clipboard line-break counting should handle LF, CRLF, and standalone CR consistently.");
var indentedChecklistMetrics = new ColumnViewModel
{
@@ -563,13 +481,66 @@ await updateService.GetLatestReleaseAsync() is null,
Check(richContentVm.IsDirty, "Promoting a text document for a picture should mark it dirty.");
var lineToggleVm = new MainViewModel();
-Check(lineToggleVm.Columns.All(c => c.LineNumberColumnWidth.IsAbsolute && Math.Abs(c.LineNumberColumnWidth.Value - ColumnViewModel.VisibleLineNumberColumnWidth) < 0.001), "Line-number gutter should default to visible width.");
+Check(lineToggleVm.Columns.All(c => c.LineNumberColumnWidth.IsAbsolute && Math.Abs(c.LineNumberColumnWidth.Value - MainViewModel.MinimumGutterWidthPx) < 0.001), "Line-number gutter should default to the smallest visible width.");
+lineToggleVm.GutterWidthPx = 36;
+Check(lineToggleVm.IsDirty, "Changing the gutter width should mark a layout dirty.");
+Check(lineToggleVm.Columns.All(c => c.LineNumberColumnWidth.IsAbsolute && Math.Abs(c.LineNumberColumnWidth.Value - 36) < 0.001), "Changing the shared gutter width should update every existing column.");
+lineToggleVm.GutterWidthPx = MainViewModel.MaximumGutterWidthPx + 1;
+Check(lineToggleVm.GutterWidthPx == MainViewModel.MaximumGutterWidthPx, "Gutter width should clamp to its maximum supported value.");
+lineToggleVm.GutterWidthPx = MainViewModel.MinimumGutterWidthPx - 1;
+Check(lineToggleVm.GutterWidthPx == MainViewModel.MinimumGutterWidthPx, "Gutter width should clamp to its minimum supported value.");
+lineToggleVm.GutterWidthPx = 36;
lineToggleVm.ShowLineNumbers = false;
Check(lineToggleVm.Columns.All(c => c.ShowLineNumbersVisibility == Visibility.Collapsed), "Turning line numbers off should collapse line-number visibility for all columns.");
Check(lineToggleVm.Columns.All(c => c.LineNumberColumnWidth.IsAbsolute && Math.Abs(c.LineNumberColumnWidth.Value) < 0.001), "Turning line numbers off should collapse gutter width for all columns.");
lineToggleVm.ShowLineNumbers = true;
Check(lineToggleVm.Columns.All(c => c.ShowLineNumbersVisibility == Visibility.Visible), "Turning line numbers back on should restore line-number visibility for all columns.");
-Check(lineToggleVm.Columns.All(c => c.LineNumberColumnWidth.IsAbsolute && Math.Abs(c.LineNumberColumnWidth.Value - ColumnViewModel.VisibleLineNumberColumnWidth) < 0.001), "Turning line numbers back on should restore gutter width for all columns.");
+Check(lineToggleVm.Columns.All(c => c.LineNumberColumnWidth.IsAbsolute && Math.Abs(c.LineNumberColumnWidth.Value - 36) < 0.001), "Turning line numbers back on should restore the chosen shared gutter width.");
+lineToggleVm.Columns[0].WidthPx = 410;
+lineToggleVm.Columns[1].WidthPx = 430;
+lineToggleVm.AddColumn();
+Check(
+ lineToggleVm.Columns[0].WidthPx == 410
+ && lineToggleVm.Columns[1].WidthPx == 430
+ && lineToggleVm.Columns[^1].WidthPx is null
+ && Math.Abs(lineToggleVm.Columns[^1].LineNumberColumnWidth.Value - 36) < 0.001,
+ "Adding a column should preserve existing widths while the new column inherits the preferred default and workspace gutter width.");
+
+var resetWidthVm = new MainViewModel();
+const int preferredColumnWidthPx = 438;
+var resetRebuildCount = 0;
+resetWidthVm.RequestRebuildColumns += (_, __) => resetRebuildCount++;
+resetWidthVm.Columns[0].WidthPx = 480;
+resetWidthVm.Columns[0].IsWidthLocked = true;
+resetWidthVm.ResetActiveColumnWidth(preferredColumnWidthPx);
+Check(
+ resetWidthVm.Columns[0].WidthPx is null
+ && !resetWidthVm.Columns[0].IsWidthLocked
+ && resetWidthVm.StatusText == $"Reset {resetWidthVm.Columns[0].Title} to the default {preferredColumnWidthPx}px width."
+ && resetRebuildCount == 1,
+ "Resetting one column should restore the preferred default width, unlock it, report that default, and rebuild the strip.");
+foreach (var column in resetWidthVm.Columns)
+{
+ column.WidthPx = 480;
+ column.IsWidthLocked = true;
+}
+resetWidthVm.ResetAllColumnWidths(preferredColumnWidthPx);
+Check(
+ resetWidthVm.Columns.All(column => column.WidthPx is null && !column.IsWidthLocked)
+ && resetWidthVm.StatusText == $"Reset all columns to the default {preferredColumnWidthPx}px width."
+ && resetRebuildCount == 2,
+ "Resetting all columns should restore the preferred default width, unlock every column, report that default, and rebuild the strip.");
+
+var malformedWidthNode = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Could not parse layout for width validation.");
+var malformedWidthColumns = malformedWidthNode["Columns"]?.AsArray() ?? throw new InvalidOperationException("Could not find columns for width validation.");
+malformedWidthColumns[0]!.AsObject()["WidthPx"] = 999_999;
+var clampedWidthLoaded = new MainViewModel();
+Check(clampedWidthLoaded.LoadFromJson(malformedWidthNode.ToJsonString(), "oversized-width"), "Layouts with oversized stored widths should still load safely.");
+Check(clampedWidthLoaded.Columns[0].WidthPx == (int)WorkspaceConstraints.MaximumColumnWidth, "Oversized stored widths should clamp to the supported maximum.");
+malformedWidthColumns[0]!.AsObject()["WidthPx"] = 0;
+var flexibleWidthLoaded = new MainViewModel();
+Check(flexibleWidthLoaded.LoadFromJson(malformedWidthNode.ToJsonString(), "zero-width"), "Layouts with a zero stored width should still load safely.");
+Check(flexibleWidthLoaded.Columns[0].WidthPx is null, "A zero stored width should restore the normal display width instead of a broken fixed width.");
var liveStatusVm = new MainViewModel();
liveStatusVm.Columns[0].Title = "Inbox";
@@ -584,10 +555,38 @@ await updateService.GetLatestReleaseAsync() is null,
cleanExportVm.Columns[1].Title = "Beta";
cleanExportVm.Columns[1].Text = "three";
var cleanTextExport = cleanExportVm.BuildExportText().Replace("\r\n", "\n", StringComparison.Ordinal);
-Check(cleanTextExport == "ColumnPad Export\nFormat: Text\n\n===== Alpha Plan =====\n\none\ntwo\n\n===== Beta =====\n\nthree\n", "Text export should use a clear marker and readable sections without trailing blank blocks.");
+Check(cleanTextExport == "ColumnPad Export\nFormat: Text\nVersion: 2\n\n===== Alpha Plan =====\n\none\ntwo\n\n===== Beta =====\n\nthree\n", "Text export should use a versioned marker and readable sections without trailing blank blocks.");
Check(!cleanTextExport.Contains("\\n", StringComparison.Ordinal), "Text export should write real line breaks, not escaped JSON-style line breaks.");
-var cleanMarkdownExport = cleanExportVm.BuildExportMarkdown().Replace("\r\n", "\n", StringComparison.Ordinal);
-Check(cleanMarkdownExport == "\n\n## Alpha Plan\n\none\ntwo\n\n## Beta\n\nthree\n", "Markdown export should stay available with a clear marker and readable sections.");
+var cleanJsonExport = cleanExportVm.BuildExportJson();
+var cleanJsonExportRoot = JsonNode.Parse(cleanJsonExport)?.AsObject() ?? throw new InvalidOperationException("Could not parse readable JSON export.");
+var cleanJsonExportColumns = cleanJsonExportRoot["Columns"]?.AsArray() ?? throw new InvalidOperationException("Readable JSON export should include columns.");
+Check(
+ cleanJsonExportRoot.Count == 3
+ && cleanJsonExportRoot["FileType"]?.GetValue() == "ColumnPadTextExport"
+ && cleanJsonExportRoot["Version"]?.GetValue() == 1
+ && cleanJsonExportColumns.Count == 2
+ && cleanJsonExportColumns[0]?.AsObject().Count == 2,
+ "JSON export should be a concise, readable title-and-text format without layout data.");
+Check(
+ cleanJsonExportColumns[0]?["Title"]?.GetValue() == "Alpha Plan"
+ && cleanJsonExportColumns[0]?["Text"]?.GetValue() == "one\r\ntwo\n\n"
+ && cleanJsonExportColumns[1]?["Title"]?.GetValue() == "Beta",
+ "JSON export should preserve normalized titles and original text exactly.");
+
+var collisionExportVm = new MainViewModel();
+collisionExportVm.SetColumnCount(2);
+collisionExportVm.Columns[0].Title = "Text boundaries";
+collisionExportVm.Columns[0].Text = "before\n===== this is body text =====\n\\leading slash";
+collisionExportVm.Columns[1].Title = "JSON boundaries";
+collisionExportVm.Columns[1].Text = "before\n## this is a body heading\n\\## literal slash heading";
+var collisionTextRoundTrip = new MainViewModel();
+collisionTextRoundTrip.LoadFromExportText(collisionExportVm.BuildExportText(), "collision.txt");
+Check(collisionTextRoundTrip.Columns.Count == 2, "Versioned text export should not split body lines that resemble column headers.");
+Check(collisionTextRoundTrip.Columns[0].Text == collisionExportVm.Columns[0].Text, "Versioned text export should preserve header-like and backslash-prefixed body lines.");
+var collisionJsonRoundTrip = new MainViewModel();
+collisionJsonRoundTrip.LoadFromExportJson(collisionExportVm.BuildExportJson(), "collision.json");
+Check(collisionJsonRoundTrip.Columns.Count == 2, "JSON export should preserve separate columns without text markers.");
+Check(collisionJsonRoundTrip.Columns[1].Text == collisionExportVm.Columns[1].Text, "JSON export should preserve headings, backslashes, and multiline text exactly.");
var exportedText = "ColumnPad Export\nFormat: Text\n\n===== Alpha =====\n\none\n\n===== Beta =====\n\n.\n";
var importedFromText = new MainViewModel();
@@ -599,10 +598,80 @@ await updateService.GetLatestReleaseAsync() is null,
Check(importedFromText.Columns[1].Text == ".", "Text import should preserve second column body.");
Check(!importedFromText.IsDirty, "Imported text exports should start clean.");
+var oversizedExport = "ColumnPad Export\nFormat: Text\n\n" + string.Join(
+ "\n\n",
+ Enumerable.Range(1, WorkspaceConstraints.MaxColumns + 1).Select(index => $"===== Column {index} =====\n\nvalue"));
+var oversizedImport = new MainViewModel();
+var oversizedImportRejected = false;
+try
+{
+ oversizedImport.LoadFromExportText(oversizedExport, "oversized.txt");
+}
+catch (InvalidDataException)
+{
+ oversizedImportRejected = true;
+}
+Check(oversizedImportRejected, "Imports above the supported column limit should be rejected before changing the workspace.");
+Check(oversizedImport.Columns.Count == 3, "A rejected oversized import should leave the existing workspace intact.");
+
var tempRoot = Path.Combine(Path.GetTempPath(), $"ColumnPadStudioSmoke-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
+ var firstImageSource = Path.Combine(tempRoot, "first.png");
+ var secondImageSource = Path.Combine(tempRoot, "second.png");
+ var imageEncoder = new PngBitmapEncoder();
+ imageEncoder.Frames.Add(BitmapFrame.Create(BitmapSource.Create(
+ 1,
+ 1,
+ 96,
+ 96,
+ PixelFormats.Bgra32,
+ null,
+ new byte[] { 0x20, 0x60, 0xA0, 0xFF },
+ 4)));
+ using (var imageStream = File.Create(firstImageSource))
+ imageEncoder.Save(imageStream);
+ File.Copy(firstImageSource, secondImageSource);
+
+ var firstImageImport = ColumnImageFileService.ImportImage(firstImageSource);
+ var secondImageImport = ColumnImageFileService.ImportImage(secondImageSource);
+ Check(firstImageImport.AssetId == secondImageImport.AssetId, "Identical picture content should receive one stable asset identity.");
+ Check(firstImageImport.Content.SequenceEqual(secondImageImport.Content), "Identical picture imports should preserve identical embedded content.");
+ Check(string.IsNullOrEmpty(firstImageImport.FilePath), "New picture imports should not create unmanaged permanent image copies.");
+ Check(firstImageImport.OriginalFileName == "first.png" && secondImageImport.OriginalFileName == "second.png", "Reused picture assets should preserve each imported display name.");
+
+ var portablePictureVm = new MainViewModel();
+ portablePictureVm.SetColumnCount(1);
+ portablePictureVm.Columns[0].Images.Add(new ColumnImageViewModel(
+ firstImageImport.FilePath,
+ firstImageImport.OriginalFileName,
+ firstImageImport.DisplayWidth,
+ firstImageImport.PixelWidth,
+ firstImageImport.PixelHeight,
+ imageContent: firstImageImport.Content));
+ var portablePictureJson = portablePictureVm.ToLayoutJson();
+ var portablePictureRoot = JsonNode.Parse(portablePictureJson)!.AsObject();
+ var portablePictureContent = portablePictureRoot["Columns"]![0]!["Images"]![0]!["Content"]?.GetValue();
+ Check(!string.IsNullOrWhiteSpace(portablePictureContent), "Native layouts should embed bounded picture content for portability.");
+ var portablePictureLoaded = new MainViewModel();
+ Check(portablePictureLoaded.LoadFromJson(portablePictureJson, "portable-picture"), "A portable-picture layout should load after its original managed file is removed.");
+ Check(portablePictureLoaded.Columns[0].Images[0].CanDisplayImage, "Embedded picture content should display without the original local path.");
+
+ var oversizedImagePath = Path.Combine(tempRoot, "oversized.png");
+ using (var oversizedImageStream = File.Create(oversizedImagePath))
+ oversizedImageStream.SetLength(ColumnImageFileService.MaxImageFileBytes + 1L);
+ var oversizedImageRejected = false;
+ try
+ {
+ _ = ColumnImageFileService.ImportImage(oversizedImagePath);
+ }
+ catch (InvalidDataException)
+ {
+ oversizedImageRejected = true;
+ }
+ Check(oversizedImageRejected, "Picture import should reject files above the bounded image size before decoding them.");
+
var tempTextPath = Path.Combine(tempRoot, "loaded.txt");
File.WriteAllText(tempTextPath, exportedText);
@@ -624,7 +693,7 @@ await updateService.GetLatestReleaseAsync() is null,
var recoveryRoot = Path.Combine(tempRoot, "recovery");
var recoveryWorkspaces = new[]
{
- new WorkspaceRecoveryWorkspace("Workspace A", vm.ToLayoutJson(), tempTextPath, SaveFileKind.TextDocument, true, true),
+ new WorkspaceRecoveryWorkspace("Workspace A", vm.ToLayoutJson(), tempTextPath, SaveFileKind.TextDocument, true, true, 5, true),
new WorkspaceRecoveryWorkspace("Workspace B", loaded.ToLayoutJson(), null, SaveFileKind.Layout, false, false)
};
@@ -636,6 +705,8 @@ await updateService.GetLatestReleaseAsync() is null,
Check(recoverySnapshot.Workspaces[0].CurrentFilePath == tempTextPath, "Recovery store should preserve file paths per workspace.");
Check(recoverySnapshot.Workspaces[0].IsDirty, "Recovery store should preserve dirty state per workspace.");
Check(recoverySnapshot.Workspaces[0].RequiresSaveAsBeforeOverwrite, "Recovery store should preserve Save As requirements per workspace.");
+ Check(recoverySnapshot.Workspaces[0].LastMultiColumnCount == 5, "Recovery store should preserve the remembered multi-column count.");
+ Check(recoverySnapshot.Workspaces[0].HasSessionChanges, "Recovery store should preserve unsaved workspace metadata.");
var recoveredWorkspaceVm = new MainViewModel();
Check(recoveredWorkspaceVm.LoadRecoverySnapshot(recoverySnapshot.Workspaces[0]), "Recovery load should accept a saved workspace snapshot.");
@@ -645,13 +716,37 @@ await updateService.GetLatestReleaseAsync() is null,
Check(recoveredWorkspaceVm.IsDirty, "Recovered dirty workspace should still be dirty.");
Check(recoveredWorkspaceVm.Columns.Count == vm.Columns.Count, "Recovered workspace should restore its layout content.");
+ var legacyMarkdownRecoveryRoot = Path.Combine(tempRoot, "legacy-markdown-recovery");
+ WorkspaceRecoveryStore.Save([recoveryWorkspaces[0]], 0, legacyMarkdownRecoveryRoot);
+ var legacyGenerationName = File.ReadAllText(Path.Combine(legacyMarkdownRecoveryRoot, "current-generation.txt")).Trim();
+ var legacyManifestPath = Path.Combine(legacyMarkdownRecoveryRoot, legacyGenerationName, "manifest.json");
+ var legacyManifest = JsonNode.Parse(File.ReadAllText(legacyManifestPath))?.AsObject() ?? throw new InvalidOperationException("Could not parse legacy recovery manifest.");
+ var legacyWorkspaceEntry = legacyManifest["Workspaces"]?[0]?.AsObject() ?? throw new InvalidOperationException("Could not find legacy recovery workspace.");
+ legacyWorkspaceEntry["CurrentFileKind"] = "MarkdownDocument";
+ legacyWorkspaceEntry["CurrentFilePath"] = "C:\\temp\\legacy.md";
+ legacyWorkspaceEntry["RequiresSaveAsBeforeOverwrite"] = true;
+ File.WriteAllText(legacyManifestPath, legacyManifest.ToJsonString());
+ Check(WorkspaceRecoveryStore.TryLoad(out var migratedMarkdownRecovery, legacyMarkdownRecoveryRoot), "Recovery should load workspaces created before Markdown file support was removed.");
+ Check(
+ migratedMarkdownRecovery.Workspaces[0].CurrentFileKind == SaveFileKind.Layout
+ && migratedMarkdownRecovery.Workspaces[0].CurrentFilePath is null
+ && !migratedMarkdownRecovery.Workspaces[0].RequiresSaveAsBeforeOverwrite,
+ "Recovered Markdown workspaces should detach from the retired file type and become native layouts.");
+
WorkspaceRecoveryStore.Save([recoveryWorkspaces[0]], 0, recoveryRoot);
Check(WorkspaceRecoveryStore.TryLoad(out var trimmedRecoverySnapshot, recoveryRoot), "Recovery store should still load after shrinking the workspace list.");
Check(trimmedRecoverySnapshot.Workspaces.Count == 1, "Recovery store should drop stale workspaces when fewer tabs are saved.");
- Check(!File.Exists(Path.Combine(recoveryRoot, "workspace-2.columnpad.json")), "Recovery store should delete stale per-workspace files.");
+ var recoveryGenerations = Directory.GetDirectories(recoveryRoot, "generation-*");
+ Check(recoveryGenerations.Length == 2, "Recovery store should retain the current and previous complete generations only.");
+
+ var currentGenerationName = File.ReadAllText(Path.Combine(recoveryRoot, "current-generation.txt")).Trim();
+ File.WriteAllText(Path.Combine(recoveryRoot, currentGenerationName, "manifest.json"), "{ damaged");
+ Check(WorkspaceRecoveryStore.TryLoad(out var fallbackRecoverySnapshot, recoveryRoot), "Recovery store should fall back when the newest generation is damaged.");
+ Check(fallbackRecoverySnapshot.Workspaces.Count == 2, "Recovery fallback should restore the previous complete generation rather than a mixed snapshot.");
- WorkspaceRecoveryStore.Clear(recoveryRoot);
+ Check(WorkspaceRecoveryStore.TryClear(recoveryRoot), "Recovery cleanup should report a successful directory removal.");
Check(!Directory.Exists(recoveryRoot), "Recovery clear should remove the recovery directory.");
+ Check(WorkspaceRecoveryStore.TryClear(recoveryRoot), "Recovery cleanup should be harmless when no recovery directory exists.");
}
finally
{
@@ -659,15 +754,27 @@ await updateService.GetLatestReleaseAsync() is null,
Directory.Delete(tempRoot, true);
}
-var exportedMarkdown = "\n\n## Red\n\nleft\n\n## Blue\n\nright\n";
-var importedFromMarkdown = new MainViewModel();
-importedFromMarkdown.LoadFromExportMarkdown(exportedMarkdown, "export.md");
-Check(importedFromMarkdown.Columns.Count == 2, "Markdown import should create one column per heading.");
-Check(importedFromMarkdown.Columns[0].Title == "Red", "Markdown import should preserve first heading title.");
-Check(importedFromMarkdown.Columns[0].Text == "left", "Markdown import should preserve first heading body.");
-Check(importedFromMarkdown.Columns[1].Title == "Blue", "Markdown import should preserve second heading title.");
-Check(importedFromMarkdown.Columns[1].Text == "right", "Markdown import should preserve second heading body.");
-Check(!importedFromMarkdown.IsDirty, "Imported markdown exports should start clean.");
+var exportedJson = """
+{
+ "FileType": "ColumnPadTextExport",
+ "Version": 1,
+ "Columns": [
+ { "Title": "Red", "Text": "left" },
+ { "Title": "Blue", "Text": "right" }
+ ]
+}
+""";
+var importedFromJson = new MainViewModel();
+importedFromJson.LoadFromExportJson(exportedJson, "export.json", "C:\\temp\\export.json");
+Check(importedFromJson.Columns.Count == 2, "JSON import should create one column per exported entry.");
+Check(importedFromJson.Columns[0].Title == "Red", "JSON import should preserve first column title.");
+Check(importedFromJson.Columns[0].Text == "left", "JSON import should preserve first column body.");
+Check(importedFromJson.Columns[1].Title == "Blue", "JSON import should preserve second column title.");
+Check(importedFromJson.Columns[1].Text == "right", "JSON import should preserve second column body.");
+Check(importedFromJson.CurrentFileKind == SaveFileKind.JsonExport && importedFromJson.RequiresSaveAsBeforeOverwrite, "Imported JSON exports should require Save As before they can overwrite the source file.");
+Check(!importedFromJson.IsDirty, "Imported JSON exports should start clean.");
+importedFromJson.GutterWidthPx = 36;
+Check(importedFromJson.CurrentFileKind == SaveFileKind.Layout && importedFromJson.CurrentFilePath is null, "Adding settings that a concise JSON export cannot represent should promote the workspace to a native layout.");
var singleLayoutJson = vm.ToLayoutJson();
var workspaceSessionJson = JsonSerializer.Serialize(new
@@ -690,12 +797,12 @@ await updateService.GetLatestReleaseAsync() is null,
Check(FileWorkflowService.ClassifyOpenFile(".txt", exportedText) == OpenFileLoadKind.TextExport, "File workflow service should classify exported text as text-export load kind.");
Check(FileWorkflowService.ClassifyOpenFile(".txt", "plain note") == OpenFileLoadKind.TextDocument, "File workflow service should classify plain text as text-document load kind.");
Check(FileWorkflowService.ClassifyOpenFile(".txt", "===== Alpha =====\n\nplain note") == OpenFileLoadKind.TextDocument, "File workflow service should not auto-split normal text files that contain divider-like lines.");
-Check(FileWorkflowService.ClassifyOpenFile(".md", exportedMarkdown) == OpenFileLoadKind.MarkdownExport, "File workflow service should classify exported markdown as markdown-export load kind.");
-Check(FileWorkflowService.ClassifyOpenFile(".md", "# note") == OpenFileLoadKind.MarkdownDocument, "File workflow service should classify plain markdown as markdown-document load kind.");
-Check(FileWorkflowService.ClassifyOpenFile(".md", "## Heading\n\nplain note") == OpenFileLoadKind.MarkdownDocument, "File workflow service should not auto-split normal markdown heading files.");
+Check(FileWorkflowService.ClassifyOpenFile(".json", exportedJson) == OpenFileLoadKind.JsonExport, "File workflow service should classify concise ColumnPad JSON exports before layout detection.");
+Check(FileWorkflowService.ClassifyOpenFile(".md", "# note") == OpenFileLoadKind.Unsupported, "File workflow service should reject retired Markdown file extensions.");
+Check(!FileWorkflowService.SupportedOpenFileFilter.Contains("*.md", StringComparison.OrdinalIgnoreCase), "The Open dialog should no longer advertise Markdown files.");
Check(FileWorkflowService.ClassifyOpenFile(".json", workspaceSessionJson) == OpenFileLoadKind.WorkspaceSession, "File workflow service should classify workspace-session JSON correctly.");
Check(FileWorkflowService.ClassifyOpenFile(".json", singleLayoutJson) == OpenFileLoadKind.LayoutJson, "File workflow service should classify single-layout JSON as layout load kind.");
-var workflowJson = JsonSerializer.Serialize(workflowDefinition, new JsonSerializerOptions { WriteIndented = true });
+var workflowJson = JsonSerializer.Serialize(workflowDefinition);
Check(FileWorkflowService.ClassifyOpenFile(".workflow.json", workflowJson) == OpenFileLoadKind.WorkflowJson, "File workflow service should classify workflow JSON so File Open can route it to Workflow Builder.");
var saveDialogDefinition = FileWorkflowService.BuildSaveDialog(SaveFileKind.TextDocument, "C:\\temp\\notes.txt", requiresSaveAsBeforeOverwrite: true);
@@ -708,8 +815,8 @@ await updateService.GetLatestReleaseAsync() is null,
var textExportDialogDefinition = FileWorkflowService.BuildSaveDialog(SaveFileKind.TextExport, currentFilePath: null, requiresSaveAsBeforeOverwrite: false);
Check(textExportDialogDefinition.FileName == "ColumnPad_export.txt", "File workflow service should provide a standard text export filename.");
-var markdownExportDialogDefinition = FileWorkflowService.BuildSaveDialog(SaveFileKind.MarkdownExport, currentFilePath: null, requiresSaveAsBeforeOverwrite: false);
-Check(markdownExportDialogDefinition.FileName == "ColumnPad_export.md", "File workflow service should provide a standard markdown export filename.");
+var jsonExportDialogDefinition = FileWorkflowService.BuildSaveDialog(SaveFileKind.JsonExport, currentFilePath: null, requiresSaveAsBeforeOverwrite: false);
+Check(jsonExportDialogDefinition.FileName == "ColumnPad_export.json" && jsonExportDialogDefinition.DefaultExt == ".json", "File workflow service should provide a standard concise JSON export filename.");
var workspaceSessionDialogDefinition = FileWorkflowService.BuildWorkspaceSessionSaveDialog("C:\\temp\\session.columnpad.json");
Check(workspaceSessionDialogDefinition.FileName == "session.columnpad.json", "File workflow service should use preferred workspace-session filename when available.");
@@ -727,6 +834,10 @@ await updateService.GetLatestReleaseAsync() is null,
Check(roundTripSessionWorkspace["LayoutJson"] is null, "Session service should not emit legacy escaped LayoutJson when saving.");
Check(WorkspaceSessionFileService.TryParseSession(roundTripSessionJson, out var parsedCleanSession), "Session service should parse its cleaned session JSON.");
Check(parsedCleanSession.Workspaces[0].LayoutJson.Contains("\"Columns\"", StringComparison.Ordinal), "Cleaned session JSON should preserve the nested layout content.");
+var futureSessionRoot = JsonNode.Parse(roundTripSessionJson)!.AsObject();
+futureSessionRoot["Version"] = 999;
+Check(!WorkspaceSessionFileService.IsWorkspaceSessionJson(futureSessionRoot.ToJsonString()), "Session detection should reject unsupported future versions.");
+Check(!WorkspaceSessionFileService.TryParseSession(futureSessionRoot.ToJsonString(), out _), "Session parsing should reject unsupported future versions without loading tabs.");
var tempSessionPath = Path.Combine(Path.GetTempPath(), $"ColumnPadSession-{Guid.NewGuid():N}.columnpad.json");
File.WriteAllText(tempSessionPath, workspaceSessionJson);
@@ -767,70 +878,8 @@ await updateService.GetLatestReleaseAsync() is null,
-var searchColumns = new List
-{
- "alpha beta",
- "gamma\nalpha",
- string.Empty
-};
-
-Check(TextSearchService.TryFindNext(searchColumns, "alpha", 0, 0, 0, SearchCursor.Empty, out var firstFind), "Text search service should find the first match from the active column.");
-Check(firstFind.ColumnIndex == 0 && firstFind.CharIndex == 0 && firstFind.LineNumber == 1, "Text search service should report first-column hit coordinates.");
-Check(TextSearchService.TryFindNext(searchColumns, "alpha", 0, 0, 0, new SearchCursor(firstFind.ColumnIndex, firstFind.CharIndex), out var secondFind), "Text search service should advance to the next match after the cursor.");
-Check(secondFind.ColumnIndex == 1 && secondFind.CharIndex == 6 && secondFind.LineNumber == 2, "Text search service should report line/char for cross-column next hit.");
-Check(TextSearchService.TryFindNext(searchColumns, "alpha", 0, 0, 0, new SearchCursor(secondFind.ColumnIndex, secondFind.CharIndex), out var wrappedFind), "Text search service should wrap when searching past the last match.");
-Check(wrappedFind.ColumnIndex == 0 && wrappedFind.CharIndex == 0, "Text search service wrap search should return to the first match.");
-Check(!TextSearchService.TryFindNext(searchColumns, "missing", 0, 0, 0, SearchCursor.Empty, out _), "Text search service should return no hit when the term is absent.");
-
-var (replacedTextByService, replacementCountByService) = TextSearchService.ReplaceAllWithCount("one One one", "one", "two", StringComparison.CurrentCultureIgnoreCase);
-Check(replacementCountByService == 3, "Text search service replace should count all case-insensitive hits.");
-Check(replacedTextByService == "two two two", "Text search service replace should substitute all hits in order.");
-Check(TextSearchService.ComputeLineNumber("a\nb\nc", 4) == 3, "Text search service should compute 1-based line numbers from character index.");
-Check(TextSearchService.ComputeLineNumber("a\rb\r\nc", 5) == 3, "Text search service should count LF, CRLF, and standalone CR line breaks consistently.");
-
-var listModeVm = new ColumnViewModel
-{
- Text = "alpha\nbeta",
- LineMarkerMode = LineMarkerMode.Bullets
-};
-Check(listModeVm.LineMarkerMode == LineMarkerMode.Bullets, "Line marker mode should support bullets without mutating text.");
-listModeVm.LineMarkerMode = LineMarkerMode.Checklist;
-listModeVm.ToggleChecklistLineChecked(0);
-Check(listModeVm.IsChecklistLineChecked(0), "Checklist gutter mode should toggle checks without inserting inline symbols.");
-Check(listModeVm.Text == "alpha\nbeta", "Checklist gutter mode should keep body text unchanged.");
-
-var expectedClipboardLines = string.Join(Environment.NewLine, "one", "two", "three");
-Check(
- ClipboardTextService.NormalizeClipboardText("one\r\r\ntwo\u2028three") == expectedClipboardLines,
- "Clipboard text normalization should collapse malformed CRCRLF and Unicode line separators.");
-
-var alternatingBlankPaste = "one\n\n two\n\nthree\n\nfour";
-Check(
- ClipboardTextService.NormalizeClipboardText(alternatingBlankPaste) == string.Join(Environment.NewLine, "one", " two", "three", "four"),
- "Clipboard text normalization should collapse alternating blank rows from malformed paste sources.");
-
-Check(
- ClipboardTextService.ApplyPastePreset("alpha\n beta", PasteListPreset.Bullets) == string.Join(Environment.NewLine, "- alpha", " - beta"),
- "Clipboard bullet preset should add markdown bullets while preserving indentation.");
-
-Check(
- ClipboardTextService.ApplyPastePreset("- [x] done\nplain", PasteListPreset.Checklist) == string.Join(Environment.NewLine, "- [x] done", "- [ ] plain"),
- "Clipboard checklist preset should preserve checked checklist rows and add unchecked markers to plain rows.");
-
-Check(
- ClipboardTextService.ApplyPastePreset("1. ordered", PasteListPreset.Bullets) == "1. ordered",
- "Clipboard paste presets should not rewrite ordered-list prefixes.");
-
-if (failures.Count > 0)
-{
- Console.Error.WriteLine($"Smoke tests failed: {failures.Count} of {checks} checks.");
- foreach (var failure in failures)
- Console.Error.WriteLine($" - {failure}");
- return 1;
-}
-
-Console.WriteLine($"Smoke tests passed ({checks} checks).");
-return 0;
+EditorServiceSmokeTests.Run(tests);
+return tests.Complete();
diff --git a/tests/ColumnPadStudio.SmokeTests/SmokeTestContext.cs b/tests/ColumnPadStudio.SmokeTests/SmokeTestContext.cs
new file mode 100644
index 0000000..186aba5
--- /dev/null
+++ b/tests/ColumnPadStudio.SmokeTests/SmokeTestContext.cs
@@ -0,0 +1,30 @@
+namespace ColumnPadStudio.SmokeTests;
+
+internal sealed class SmokeTestContext
+{
+ private readonly List _failures = [];
+
+ public int CheckCount { get; private set; }
+
+ public void Check(bool condition, string message)
+ {
+ CheckCount++;
+ if (!condition)
+ _failures.Add(message);
+ }
+
+ public int Complete()
+ {
+ if (_failures.Count == 0)
+ {
+ Console.WriteLine($"Smoke tests passed ({CheckCount} checks).");
+ return 0;
+ }
+
+ Console.Error.WriteLine($"Smoke tests failed: {_failures.Count} of {CheckCount} checks.");
+ foreach (var failure in _failures)
+ Console.Error.WriteLine($" - {failure}");
+
+ return 1;
+ }
+}
diff --git a/tests/ColumnPadStudio.SmokeTests/ThemeAndControlSmokeTests.cs b/tests/ColumnPadStudio.SmokeTests/ThemeAndControlSmokeTests.cs
new file mode 100644
index 0000000..5c33c92
--- /dev/null
+++ b/tests/ColumnPadStudio.SmokeTests/ThemeAndControlSmokeTests.cs
@@ -0,0 +1,861 @@
+using ColumnPadStudio.Controls;
+using ColumnPadStudio.Models;
+using ColumnPadStudio.Services;
+using ColumnPadStudio.ViewModels;
+using System.Globalization;
+using System.IO;
+using System.Windows;
+using System.Windows.Automation;
+using System.Windows.Controls;
+using System.Windows.Media;
+using System.Windows.Threading;
+using System.Xml.Linq;
+
+namespace ColumnPadStudio.SmokeTests;
+
+internal static class ThemeAndControlSmokeTests
+{
+ private static readonly string[] ComboBoxItems = ["First", "Second"];
+
+ public static void Run(SmokeTestContext tests)
+ {
+ VerifyColumnWidthMenuXaml(tests);
+
+ Exception? resourceLoadException = null;
+ Thread resourceLoadThread = new(() =>
+ {
+ try
+ {
+ _ = new Application
+ {
+ ShutdownMode = ShutdownMode.OnExplicitShutdown
+ };
+ var resources = new ResourceDictionary
+ {
+ Source = new Uri("pack://application:,,,/ColumnPadStudio;component/Resources/AppResources.xaml", UriKind.Absolute)
+ };
+
+ tests.Check(resources.MergedDictionaries.Count == 3, "App resources should stay split into theme brushes, control styles, and menu styles.");
+ tests.Check(resources["ControlPopupHighlightBrush"] is not null, "Theme brush resources should load from the app resource index.");
+ tests.Check(resources["EditorTextBlueBrush"] is SolidColorBrush, "Theme resources should expose the column text-colour palette.");
+ tests.Check(resources["PaperPatternBrush"] is SolidColorBrush, "Theme resources should expose the shared paper pattern colour.");
+ tests.Check(resources[typeof(MenuItem)] is Style, "Shared menu item style should load from the app resource index.");
+ tests.Check(resources["EmbeddedMenuPanelItemStyle"] is Style, "Embedded menu panel style should load from the app resource index.");
+ tests.Check(resources[typeof(Button)] is Style, "Shared button style should load from the app resource index.");
+ tests.Check(resources[typeof(TextBox)] is Style, "Shared textbox style should load from the app resource index.");
+
+ Application.Current.Resources.MergedDictionaries.Add(resources);
+
+ var styledButton = new Button { Content = "Template check" };
+ styledButton.Style = (Style)resources[typeof(Button)];
+ styledButton.ApplyTemplate();
+ tests.Check(styledButton.Template is not null, "Shared button style should apply without missing resource errors.");
+
+ var styledTextBox = new TextBox { Text = "Template check" };
+ styledTextBox.Style = (Style)resources[typeof(TextBox)];
+ styledTextBox.ApplyTemplate();
+ tests.Check(styledTextBox.Template is not null, "Shared textbox style should apply without missing resource errors.");
+ tests.Check(styledTextBox.IsInactiveSelectionHighlightEnabled, "Text selection should remain visible when focus moves to a menu or another control.");
+
+ var styledComboBox = new ComboBox { ItemsSource = ComboBoxItems, SelectedIndex = 0 };
+ styledComboBox.Style = (Style)resources[typeof(ComboBox)];
+ var styledTabItem = new TabItem { Header = "Focus tab" };
+ var styledTabControl = new TabControl { Items = { styledTabItem } };
+ var focusPanel = new StackPanel
+ {
+ Children =
+ {
+ styledButton,
+ styledTextBox,
+ styledComboBox,
+ styledTabControl
+ }
+ };
+ var focusHost = new Window
+ {
+ Width = 320,
+ Height = 220,
+ Content = focusPanel,
+ ShowInTaskbar = false,
+ WindowStyle = WindowStyle.None
+ };
+ focusHost.Show();
+ focusHost.Activate();
+ focusHost.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+
+ VerifyKeyboardFocusBorder(styledButton, "ButtonBorder", "Buttons");
+ VerifyKeyboardFocusBorder(styledComboBox, "ComboBorder", "Drop-downs");
+ VerifyKeyboardFocusBorder(styledTabItem, "TabBorder", "Tabs");
+ focusHost.Close();
+
+ Color VerifySelectionPalette(string preset)
+ {
+ ThemeResourceService.ApplyTheme(resources, preset);
+ var editorBackground = ((SolidColorBrush)resources["EditorBackgroundBrush"]).Color;
+ var selection = ((SolidColorBrush)resources["EditorSelectionBrush"]).Color;
+ var selectionText = ((SolidColorBrush)resources["EditorSelectionTextBrush"]).Color;
+ var inactiveSelection = ((SolidColorBrush)resources[SystemColors.InactiveSelectionHighlightBrushKey]).Color;
+ var inactiveSelectionText = ((SolidColorBrush)resources[SystemColors.InactiveSelectionHighlightTextBrushKey]).Color;
+ var systemSelection = ((SolidColorBrush)resources[SystemColors.HighlightBrushKey]).Color;
+ var systemSelectionText = ((SolidColorBrush)resources[SystemColors.HighlightTextBrushKey]).Color;
+
+ tests.Check(
+ SelectionContrast(Blend(editorBackground, selection, styledTextBox.SelectionOpacity), selectionText) >= 4.5,
+ $"{preset} translucent active selection should keep selected text readable.");
+ tests.Check(SelectionContrast(inactiveSelection, inactiveSelectionText) >= 4.5, $"{preset} inactive selection should keep selected text readable.");
+ tests.Check(selection == systemSelection && selectionText == systemSelectionText, $"{preset} editor and native text selection should use one palette.");
+ tests.Check(resources["PaperPatternBrush"] is SolidColorBrush, $"{preset} should expose a paper pattern colour.");
+ return ((SolidColorBrush)resources["EditorTextBlueBrush"]).Color;
+ }
+
+ void VerifyKeyboardFocusBorder(Control control, string borderName, string description)
+ {
+ control.ApplyTemplate();
+ control.Focus();
+ control.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ var border = control.Template.FindName(borderName, control) as Border;
+ var expectedFocusColor = ((SolidColorBrush)resources["ControlFocusBorderBrush"]).Color;
+ tests.Check(
+ control.IsKeyboardFocused
+ && border?.BorderBrush is SolidColorBrush borderBrush
+ && borderBrush.Color == expectedFocusColor,
+ $"{description} should use the shared keyboard-focus border.");
+ }
+
+ var darkBlue = VerifySelectionPalette(ThemePresetService.DarkPreset);
+ var lightBlue = VerifySelectionPalette(ThemePresetService.LightPreset);
+ tests.Check(darkBlue != lightBlue, "Preset text colours should adapt between dark and light themes.");
+ VerifySelectionPalette(ThemePresetService.DefaultPreset);
+
+ var paperVm = new MainViewModel { LinedPaperEnabled = true };
+ var columnEditorVm = paperVm.Columns[0];
+ columnEditorVm.EditorTextColor = ColumnTextColorService.Blue;
+ var columnEditor = new ColumnEditorControl { DataContext = columnEditorVm };
+ var columnEditorHost = new Window
+ {
+ Width = 420,
+ Height = 320,
+ Content = columnEditor,
+ DataContext = new PaperHostContext(paperVm),
+ ShowInTaskbar = false,
+ WindowStyle = WindowStyle.None
+ };
+ columnEditorHost.Show();
+ columnEditor.ApplyTemplate();
+ tests.Check(columnEditor.FindName("ColumnTextColorMenuItem") is MenuItem, "Column formatting should expose one text-colour submenu.");
+ var columnActionsButton = columnEditor.FindName("ColumnActionsButton") as Button;
+ var headerGrip = columnEditor.FindName("HeaderGrip") as Border;
+ var headerRenameMenu = headerGrip?.ContextMenu;
+ var headerRenameItems = headerRenameMenu?.Items.OfType().ToArray() ?? [];
+ var headerRenameItem = headerRenameItems.SingleOrDefault();
+ var actionsContextMenu = columnActionsButton?.ContextMenu;
+ var actionMenuItems = actionsContextMenu?.Items.OfType().ToArray() ?? [];
+ tests.Check(columnActionsButton is not null && columnActionsButton.Visibility == Visibility.Visible, "Every column header should expose a visible Actions menu button.");
+ tests.Check(columnActionsButton is not null && AutomationProperties.GetName(columnActionsButton) == "Column actions", "Column Actions should have an accessible name.");
+ tests.Check(
+ headerRenameItem is not null && Equals(headerRenameItem.Header, "Rename Column"),
+ "Right-clicking a column header should expose Rename Column only.");
+ tests.Check(
+ actionMenuItems.Any(item => Equals(item.Header, "Rename Column"))
+ && actionMenuItems.Any(item => Equals(item.Header, "Resize This Column..."))
+ && actionMenuItems
+ .FirstOrDefault(item => Equals(item.Header, "Column Font Settings"))?
+ .Items.OfType().Any(item => Equals(item.Header, "Text Colour")) == true,
+ "Column Actions should retain the complete column menu, including text colour.");
+ columnEditorVm.IsRenaming = false;
+ if (headerRenameItem is not null)
+ headerRenameItem.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
+ tests.Check(headerRenameItem is not null && columnEditorVm.IsRenaming, "The header right-click Rename Column action should still start inline renaming.");
+ columnEditorVm.IsRenaming = false;
+ var actionOpenCount = 0;
+ var resetWidthRequestCount = 0;
+ var resizeRequestCount = 0;
+ var lockWidthRequestCount = 0;
+ columnEditor.ColumnActionsOpening += (_, __) => actionOpenCount++;
+ columnEditor.ResetWidthRequested += (_, __) => resetWidthRequestCount++;
+ columnEditor.ResizeRequested += (_, __) => resizeRequestCount++;
+ columnEditor.LockWidthRequested += (_, __) => lockWidthRequestCount++;
+ columnActionsButton?.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(actionOpenCount == 1, "Opening Column Actions should activate its column before showing the menu.");
+ tests.Check(ReferenceEquals(actionsContextMenu?.PlacementTarget, columnActionsButton), "Column Actions should open its dedicated full menu.");
+ tests.Check(headerGrip is not null && headerRenameMenu is not null, "Column headers should retain their Rename-only right-click menu.");
+ var resetWidthMenuItem = actionMenuItems.SingleOrDefault(item => Equals(item.Header, "Reset This Column to Default Width"));
+ var resizeColumnMenuItem = actionMenuItems.SingleOrDefault(item => Equals(item.Header, "Resize This Column..."));
+ var widthLockMenuItem = actionMenuItems.SingleOrDefault(item => Equals(item.Header, columnEditorVm.WidthLockActionLabel));
+ var rightEdgeResizeThumb = columnEditor.FindName("RightEdgeResizeThumb") as System.Windows.Controls.Primitives.Thumb;
+ tests.Check(
+ resetWidthMenuItem?.IsEnabled == true
+ && resizeColumnMenuItem?.IsEnabled == true
+ && widthLockMenuItem?.IsEnabled == true
+ && rightEdgeResizeThumb?.IsEnabled == true,
+ "Reset, resize, freeze, and drag-resize controls should be enabled for a normal multi-column strip.");
+
+ columnEditorVm.IsWidthManagementEnabled = false;
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(
+ resetWidthMenuItem?.IsEnabled == false
+ && resizeColumnMenuItem?.IsEnabled == false
+ && widthLockMenuItem?.IsEnabled == false
+ && rightEdgeResizeThumb?.IsEnabled == false
+ && rightEdgeResizeThumb.Visibility == Visibility.Collapsed,
+ "Fit-to-window sizing should disable the per-column reset, resize, and freeze controls.");
+
+ columnEditorVm.IsWidthManagementEnabled = true;
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ resetWidthMenuItem?.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
+ resizeColumnMenuItem?.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
+ widthLockMenuItem?.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
+ tests.Check(
+ resetWidthRequestCount == 1 && resizeRequestCount == 1 && lockWidthRequestCount == 1,
+ "Enabled Column Actions should keep raising the existing reset, resize, and freeze requests.");
+
+ columnEditorVm.IsWidthLocked = true;
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(
+ Equals(widthLockMenuItem?.Header, "Allow Resize")
+ && widthLockMenuItem?.IsEnabled == true
+ && rightEdgeResizeThumb?.IsEnabled == false,
+ "A frozen column should disable edge dragging while keeping Allow Resize available.");
+ columnEditorVm.IsWidthLocked = false;
+ if (actionsContextMenu is not null)
+ actionsContextMenu.IsOpen = false;
+ columnEditorVm.IsStandaloneDocument = true;
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(columnActionsButton?.Visibility == Visibility.Visible, "Column Actions should remain available in Single Text Mode.");
+
+ var editorSurface = columnEditor.FindName("EditorSurface") as Grid;
+ tests.Check(
+ editorSurface?.ColumnDefinitions.Count == 2
+ && Math.Abs(editorSurface.ColumnDefinitions[0].Width.Value - MainViewModel.MinimumGutterWidthPx) < 0.001,
+ "A standalone editor should start with the smallest shared gutter width without depending on a window binding.");
+ paperVm.GutterWidthPx = 64;
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(editorSurface is not null && Math.Abs(editorSurface.ColumnDefinitions[0].Width.Value - 64) < 0.001, "Changing the workspace gutter width should immediately update the editor.");
+ paperVm.ShowLineNumbers = false;
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(editorSurface is not null && Math.Abs(editorSurface.ColumnDefinitions[0].Width.Value) < 0.001, "Hiding line numbers should collapse the editor gutter.");
+ paperVm.ShowLineNumbers = true;
+ paperVm.AddColumn();
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(
+ editorSurface is not null
+ && Math.Abs(editorSurface.ColumnDefinitions[0].Width.Value - 64) < 0.001
+ && Math.Abs(paperVm.Columns[^1].LineNumberColumnWidth.Value - 64) < 0.001,
+ "Restoring line numbers and adding a column should retain the shared gutter width.");
+ var paperBackground = columnEditor.FindName("EditorPaperBackground") as PaperBackground;
+ var lineNumberPaperBackground = columnEditor.FindName("LineNumberPaperBackground") as PaperBackground;
+ tests.Check(
+ paperBackground?.IsPaperEnabled == true
+ && lineNumberPaperBackground?.IsPaperEnabled == true
+ && Math.Abs(paperBackground.LineHeight - columnEditorVm.EditorLineHeight) < 0.001,
+ "Lined paper should fill both surfaces and follow the column's real text line height.");
+ paperVm.UsePaperStyle(PaperStyle.SoftRuled);
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(
+ paperBackground?.PaperStyle == PaperStyle.SoftRuled
+ && lineNumberPaperBackground?.PaperStyle == PaperStyle.SoftRuled,
+ "Soft ruled paper should update both the writing surface and gutter from the shared paper setting.");
+ paperVm.UsePaperStyle(PaperStyle.StrongRuled);
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(
+ paperBackground?.PaperStyle == PaperStyle.StrongRuled
+ && lineNumberPaperBackground?.PaperStyle == PaperStyle.StrongRuled,
+ "Strong ruled paper should update both the writing surface and gutter.");
+ paperVm.LinedPaperEnabled = false;
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(
+ paperBackground?.IsPaperEnabled == false
+ && lineNumberPaperBackground?.IsPaperEnabled == false,
+ "Switching paper off should restore the normal editor and gutter backgrounds.");
+ paperVm.UsePaperStyle(PaperStyle.Ruled);
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ var editorTextBox = columnEditor.FindName("Editor") as TextBox;
+ var lineNumbers = columnEditor.FindName("LineNumbers") as TextBlock;
+ tests.Check(editorTextBox is not null, "Column editor should expose its text surface for formatting bindings.");
+ editorTextBox?.ApplyTemplate();
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ var editorScrollViewer = editorTextBox is null ? null : FindDescendant(editorTextBox);
+ tests.Check(
+ editorTextBox?.VerticalScrollBarVisibility == ScrollBarVisibility.Auto,
+ "Each column editor should keep its own automatic vertical scrollbar.");
+ if (editorTextBox is not null)
+ {
+ editorTextBox.Text = string.Join(Environment.NewLine, Enumerable.Range(1, 100).Select(index => $"Scroll line {index}"));
+ editorTextBox.ScrollToLine(40);
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(
+ editorScrollViewer?.ScrollableHeight > 0
+ && editorScrollViewer.ComputedVerticalScrollBarVisibility == Visibility.Visible,
+ "Overflowing text should show the individual column's vertical scrollbar.");
+ tests.Check(
+ paperBackground is not null
+ && lineNumberPaperBackground is not null
+ && paperBackground.VerticalOffset > 0
+ && Math.Abs(paperBackground.VerticalOffset - lineNumberPaperBackground.VerticalOffset) < 0.001,
+ "Paper rules should move with the editor and gutter when the text is scrolled.");
+ }
+ tests.Check(editorTextBox?.IsInactiveSelectionHighlightEnabled == true, "Column text selection should remain visible while its context menu is open.");
+ tests.Check(
+ editorTextBox is not null && Math.Abs(editorTextBox.SelectionOpacity - 0.45) < 0.001,
+ "Column selection fill should stay translucent so it cannot cover the selected text.");
+ tests.Check(
+ editorTextBox?.Foreground is SolidColorBrush presetBlueBrush
+ && presetBlueBrush.Color == ((SolidColorBrush)resources["EditorTextBlueBrush"]).Color,
+ "A preset column text colour should bind to the current theme palette.");
+
+ ThemeResourceService.ApplyTheme(Application.Current.Resources, ThemePresetService.DarkPreset);
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(
+ editorTextBox?.Foreground is SolidColorBrush darkPresetBlueBrush
+ && darkPresetBlueBrush.Color == ((SolidColorBrush)Application.Current.Resources["EditorTextBlueBrush"]).Color,
+ "Preset column text colour should update when the app switches to dark mode.");
+
+ columnEditorVm.EditorTextColor = "#123456";
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(
+ editorTextBox?.Foreground is SolidColorBrush customTextBrush
+ && customTextBrush.Color == Color.FromRgb(0x12, 0x34, 0x56),
+ "A custom column text colour should bind to its exact RGB value.");
+
+ columnEditorVm.EditorTextColor = ColumnTextColorService.ThemeDefault;
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(
+ editorTextBox?.Foreground is SolidColorBrush themeTextBrush
+ && themeTextBrush.Color == ((SolidColorBrush)Application.Current.Resources["EditorForegroundBrush"]).Color,
+ "Resetting column text colour should restore the current theme foreground.");
+
+ if (editorTextBox is not null && lineNumbers is not null)
+ {
+ editorTextBox.Text = string.Join(Environment.NewLine, Enumerable.Range(1, 10_000).Select(index => $"Pasted line {index}"));
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ var pastedLabels = lineNumbers.Text.Split([Environment.NewLine], StringSplitOptions.None);
+ tests.Check(editorTextBox.LineCount == 10_000, "A large pasted document should keep its expected line count.");
+ tests.Check(pastedLabels.Length == editorTextBox.LineCount && pastedLabels[0] == "1" && pastedLabels[^1] == "10000", "The gutter should remain correctly numbered after a large paste.");
+
+ editorTextBox.Text = string.Join(" ", Enumerable.Repeat("long wrapped text", 1_000));
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ var wrappedLabels = lineNumbers.Text.Split([Environment.NewLine], StringSplitOptions.None);
+ tests.Check(editorTextBox.LineCount > 1, "Long text should create multiple visible rows when wrapping is enabled.");
+ tests.Check(wrappedLabels.Length == editorTextBox.LineCount && wrappedLabels[^1] == editorTextBox.LineCount.ToString(CultureInfo.InvariantCulture), "The gutter should remain aligned with wrapped text rows.");
+
+ var firstChecklistLine = string.Join(" ", Enumerable.Repeat("wrapped checklist item", 30));
+ var secondChecklistLine = "Second logical checklist item";
+ var wrappedChecklistText = firstChecklistLine + Environment.NewLine + secondChecklistLine;
+ var secondLogicalLineStart = firstChecklistLine.Length + Environment.NewLine.Length;
+ editorTextBox.Text = wrappedChecklistText;
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+
+ var secondLogicalVisualLine = editorTextBox.GetLineIndexFromCharacterIndex(secondLogicalLineStart);
+ tests.Check(
+ secondLogicalVisualLine > 1,
+ "The checklist mapping check needs the first logical line to span continuation rows.");
+
+ columnEditor.ShowGutterBullets();
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ var bulletLabels = lineNumbers.Text.Split([Environment.NewLine], StringSplitOptions.None);
+ tests.Check(
+ bulletLabels.Length == editorTextBox.LineCount && bulletLabels.All(label => label == "\u2022"),
+ "Bullet mode should continue to mark every wrapped visual row.");
+
+ columnEditor.ShowGutterChecklist();
+ columnEditor.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ var checklistLabels = lineNumbers.Text.Split([Environment.NewLine], StringSplitOptions.None);
+ tests.Check(
+ secondLogicalVisualLine > 1
+ && checklistLabels.Length == editorTextBox.LineCount
+ && checklistLabels[0] == "\u2610"
+ && checklistLabels.Skip(1).Take(secondLogicalVisualLine - 1).All(string.IsNullOrEmpty)
+ && checklistLabels[secondLogicalVisualLine] == "\u2610",
+ "Checklist mode should show one checkbox per logical line and blank wrapped continuation rows.");
+
+ if (secondLogicalVisualLine > 1)
+ {
+ var continuationVisualLine = 1;
+ var continuationCharacterIndex = editorTextBox.GetCharacterIndexFromLineIndex(continuationVisualLine);
+
+ editorTextBox.Select(continuationCharacterIndex, 1);
+ columnEditor.ToggleChecklistChecksInSelection();
+ tests.Check(
+ columnEditorVm.IsChecklistLineChecked(0)
+ && !columnEditorVm.IsChecklistLineChecked(1),
+ "A selection on a wrapped continuation row should toggle its logical checklist item.");
+ columnEditorVm.ToggleChecklistLineChecked(0);
+
+ var toggleVisualLineMethod = typeof(ColumnEditorControl).GetMethod(
+ "ToggleChecklistCheckAtVisualLine",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ tests.Check(toggleVisualLineMethod is not null, "The gutter click path should expose one visual-to-logical toggle boundary.");
+ toggleVisualLineMethod?.Invoke(columnEditor, [continuationVisualLine]);
+ tests.Check(
+ columnEditorVm.IsChecklistLineChecked(0)
+ && !columnEditorVm.IsChecklistLineChecked(1),
+ "Clicking a wrapped gutter continuation row should toggle the original logical checklist item.");
+ columnEditorVm.ToggleChecklistLineChecked(0);
+
+ var gutterContextLineField = typeof(ColumnEditorControl).GetField(
+ "_gutterContextLineIndex",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ var toggleContextMenuItem = columnEditor.FindName("LineMarkerToggleCheckMenuItem") as MenuItem;
+ tests.Check(
+ gutterContextLineField is not null && toggleContextMenuItem is not null,
+ "The gutter context-menu mapping check should find its visual-row state and toggle command.");
+ gutterContextLineField?.SetValue(columnEditor, continuationVisualLine);
+ toggleContextMenuItem?.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
+ tests.Check(
+ columnEditorVm.IsChecklistLineChecked(0)
+ && !columnEditorVm.IsChecklistLineChecked(1),
+ "The gutter context menu should map a wrapped continuation row to its logical checklist item.");
+ columnEditorVm.ToggleChecklistLineChecked(0);
+
+ gutterContextLineField?.SetValue(columnEditor, -1);
+ editorTextBox.Select(continuationCharacterIndex, 0);
+ toggleContextMenuItem?.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
+ tests.Check(
+ columnEditorVm.IsChecklistLineChecked(0)
+ && !columnEditorVm.IsChecklistLineChecked(1),
+ "The gutter menu caret fallback should resolve the logical line directly under word wrap.");
+ columnEditorVm.ToggleChecklistLineChecked(0);
+
+ editorTextBox.Select(
+ continuationCharacterIndex,
+ secondLogicalLineStart + 1 - continuationCharacterIndex);
+ columnEditor.ToggleChecklistChecksInSelection();
+ tests.Check(
+ columnEditorVm.IsChecklistLineChecked(0)
+ && columnEditorVm.IsChecklistLineChecked(1),
+ "A wrapped multi-line selection should toggle each logical checklist item exactly once.");
+ }
+ }
+ ThemeResourceService.ApplyTheme(Application.Current.Resources, ThemePresetService.DefaultPreset);
+ columnEditorHost.Close();
+ VerifyWorkspaceEditorCache(tests);
+ VerifyColumnEditorStateReuse(tests);
+
+ var workflowBuilderWindow = new WorkflowBuilderWindow();
+ workflowBuilderWindow.ApplyTemplate();
+ tests.Check(workflowBuilderWindow.ViewModel is not null, "Workflow Builder window should initialize its view model.");
+ tests.Check(workflowBuilderWindow.Owner is null, "Workflow Builder should stay independent from the main window so minimizing ColumnPad does not minimize it.");
+ tests.Check(workflowBuilderWindow.ShowInTaskbar, "Workflow Builder should have its own taskbar entry.");
+ tests.Check(workflowBuilderWindow.WindowStartupLocation == WindowStartupLocation.CenterScreen, "Workflow Builder should open as an independent window, not as an owned child.");
+ tests.Check(workflowBuilderWindow.FindName("ExportWorkflowButton") is Button, "Workflow Builder should expose one grouped export action instead of separate export buttons.");
+ workflowBuilderWindow.Close();
+
+ var nestedMenu = new MenuItem { Header = "Column colour" };
+ nestedMenu.Style = (Style)resources[typeof(MenuItem)];
+ nestedMenu.Items.Add(new MenuItem { Header = "Blue" });
+ nestedMenu.Items.Add(new MenuItem { Header = "Green" });
+
+ var contextMenu = new ContextMenu();
+ contextMenu.Items.Add(nestedMenu);
+ contextMenu.ApplyTemplate();
+ nestedMenu.ApplyTemplate();
+ nestedMenu.IsSubmenuOpen = true;
+ contextMenu.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(nestedMenu.Template is not null, "Nested context menu items should apply the shared app menu template.");
+ nestedMenu.IsSubmenuOpen = false;
+ }
+ catch (Exception ex)
+ {
+ resourceLoadException = ex;
+ }
+ });
+
+ resourceLoadThread.SetApartmentState(ApartmentState.STA);
+ resourceLoadThread.Start();
+ resourceLoadThread.Join();
+ tests.Check(resourceLoadException is null, $"App resource dictionaries should load without XAML errors: {resourceLoadException?.Message}");
+ }
+
+ private static void VerifyColumnWidthMenuXaml(SmokeTestContext tests)
+ {
+ var mainWindowXamlPath = FindRepositoryFile("src", "ColumnPadStudio", "MainWindow.xaml");
+
+ tests.Check(mainWindowXamlPath is not null, "The smoke run should be able to locate MainWindow.xaml for menu-contract checks.");
+ if (mainWindowXamlPath is null)
+ return;
+
+ var document = XDocument.Load(mainWindowXamlPath);
+ var menuItems = document
+ .Descendants()
+ .Where(element => element.Name.LocalName == "MenuItem")
+ .ToArray();
+ var columnsMenu = menuItems.SingleOrDefault(item => (string?)item.Attribute("Header") == "_Columns");
+ var directColumnMenuItems = columnsMenu?
+ .Elements()
+ .Where(element => element.Name.LocalName == "MenuItem")
+ .ToArray() ?? [];
+ var widthMenu = directColumnMenuItems.SingleOrDefault(item => (string?)item.Attribute("Header") == "Column _Width");
+ var widthMenuItems = widthMenu?
+ .Elements()
+ .Where(element => element.Name.LocalName == "MenuItem")
+ .ToArray() ?? [];
+
+ var standardItem = widthMenuItems.SingleOrDefault(item => (string?)item.Attribute("Header") == "_Standard (320 px)");
+ tests.Check(
+ (string?)standardItem?.Attribute("IsCheckable") == "True"
+ && (string?)standardItem?.Attribute("IsChecked") == "{Binding IsStandardColumnWidthSelected, Mode=OneWay}"
+ && (string?)standardItem?.Attribute("Click") == "UseStandardColumnWidth_Click",
+ "Column Width should expose the checked Standard 320px preference action.");
+
+ var customItem = widthMenuItems.SingleOrDefault(item => (string?)item.Attribute("Header") == "{Binding CustomColumnWidthMenuHeader}");
+ tests.Check(
+ (string?)customItem?.Attribute("IsCheckable") == "True"
+ && (string?)customItem?.Attribute("IsChecked") == "{Binding IsCustomColumnWidthSelected, Mode=OneWay}"
+ && (string?)customItem?.Attribute("Click") == "SetDefaultColumnWidth_Click",
+ "Column Width should expose the checked Custom preference action.");
+
+ var fitItem = widthMenuItems.SingleOrDefault(item => (string?)item.Attribute("Header") == "_Fit Columns to Window");
+ tests.Check(
+ (string?)fitItem?.Attribute("IsCheckable") == "True"
+ && (string?)fitItem?.Attribute("IsChecked") == "{Binding FitColumnsToWindow, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}",
+ "Column Width should expose Fit Columns to Window as an independent checked mode.");
+
+ var resetSelectedItem = directColumnMenuItems.SingleOrDefault(item => (string?)item.Attribute("Header") == "Reset Selected to _Default Width");
+ var resetAllItem = directColumnMenuItems.SingleOrDefault(item => (string?)item.Attribute("Header") == "Reset _All to Default Width");
+ tests.Check(
+ (string?)resetSelectedItem?.Attribute("Click") == "ResetActiveWidth_Click"
+ && (string?)resetSelectedItem?.Attribute("IsEnabled") == "{Binding CanManageColumnWidths}"
+ && (string?)resetAllItem?.Attribute("Click") == "ResetWidths_Click"
+ && (string?)resetAllItem?.Attribute("IsEnabled") == "{Binding CanManageColumnWidths}"
+ && (string?)resetAllItem?.Attribute("InputGestureText") == "Ctrl+R",
+ "Columns should retain both guarded reset actions under their clearer default-width names.");
+ tests.Check(
+ directColumnMenuItems.All(item => (string?)item.Attribute("Header") is not "Reset Column Size" and not "Reset All Column Sizes"),
+ "The Columns menu should not retain the ambiguous legacy reset-size labels.");
+
+ var editorSurfacePath = FindRepositoryFile("src", "ColumnPadStudio", "MainWindow.EditorSurface.cs");
+ tests.Check(editorSurfacePath is not null, "The smoke run should locate the main width command handlers for guard checks.");
+ if (editorSurfacePath is not null)
+ {
+ var editorSurfaceSource = File.ReadAllText(editorSurfacePath);
+ tests.Check(
+ HasWidthManagementGuardBeforeCall(editorSurfaceSource, "ResetWidths_Click", "ResetAllColumnsToDefault(ActiveVm)")
+ && HasWidthManagementGuardBeforeCall(editorSurfaceSource, "ResetActiveWidth_Click", "ResetSelectedColumnToDefault(ActiveVm)"),
+ "Reset handlers should guard keyboard and direct calls while Fit or single-column sizing disables width management.");
+ }
+ }
+
+ private static string? FindRepositoryFile(params string[] relativePathSegments)
+ {
+ var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (currentDirectory is not null)
+ {
+ var candidateSegments = new[] { currentDirectory.FullName }.Concat(relativePathSegments).ToArray();
+ var candidate = Path.Combine(candidateSegments);
+ if (File.Exists(candidate))
+ return candidate;
+
+ currentDirectory = currentDirectory.Parent;
+ }
+
+ return null;
+ }
+
+ private static bool HasWidthManagementGuardBeforeCall(string source, string methodName, string guardedCall)
+ {
+ var methodStart = source.IndexOf($"private void {methodName}", StringComparison.Ordinal);
+ if (methodStart < 0)
+ return false;
+
+ var nextMethodStart = source.IndexOf("\n private ", methodStart + 1, StringComparison.Ordinal);
+ var methodBody = nextMethodStart < 0
+ ? source[methodStart..]
+ : source[methodStart..nextMethodStart];
+ var guardIndex = methodBody.IndexOf("if (!CanManageColumnWidths)", StringComparison.Ordinal);
+ var returnIndex = methodBody.IndexOf("return;", guardIndex + 1, StringComparison.Ordinal);
+ var callIndex = methodBody.IndexOf(guardedCall, StringComparison.Ordinal);
+ return guardIndex >= 0 && returnIndex > guardIndex && callIndex > returnIndex;
+ }
+
+ private static void VerifyWorkspaceEditorCache(SmokeTestContext tests)
+ {
+ var cache = new WorkspaceColumnEditorCache();
+ var firstVm = new MainViewModel();
+ var firstWorkspace = new WorkspaceSession("First", firstVm);
+ var firstColumn = firstVm.Columns[0];
+ var replacementColumn = firstVm.Columns[1];
+ var factoryCallCount = 0;
+
+ ColumnEditorControl CreateEditor(ColumnViewModel column)
+ {
+ factoryCallCount++;
+ return new ColumnEditorControl { DataContext = column };
+ }
+
+ var firstEditor = cache.GetOrCreate(
+ firstWorkspace,
+ "stable-column",
+ firstColumn,
+ () => CreateEditor(firstColumn),
+ out var firstReplacedEditor);
+ var reusedEditor = cache.GetOrCreate(
+ firstWorkspace,
+ "stable-column",
+ firstColumn,
+ () => CreateEditor(firstColumn),
+ out var reusedReplacedEditor);
+
+ tests.Check(
+ ReferenceEquals(firstEditor, reusedEditor)
+ && firstReplacedEditor is null
+ && reusedReplacedEditor is null
+ && factoryCallCount == 1,
+ "The editor cache should reuse one control for the same workspace and column instance without rewiring it.");
+
+ firstColumn.WidthPx = 476;
+ firstColumn.IsWidthLocked = true;
+ firstVm.ResetActiveColumnWidth(438);
+ var resetWidthReusedEditor = cache.GetOrCreate(
+ firstWorkspace,
+ "stable-column",
+ firstColumn,
+ () => CreateEditor(firstColumn),
+ out var resetWidthReplacedEditor);
+ tests.Check(
+ ReferenceEquals(firstEditor, resetWidthReusedEditor)
+ && resetWidthReplacedEditor is null
+ && firstColumn.WidthPx is null
+ && !firstColumn.IsWidthLocked
+ && factoryCallCount == 1,
+ "Resetting a column width should reuse its existing editor control and preserve its event wiring.");
+
+ var replacementEditor = cache.GetOrCreate(
+ firstWorkspace,
+ "stable-column",
+ replacementColumn,
+ () => CreateEditor(replacementColumn),
+ out var replacedEditor);
+ tests.Check(
+ !ReferenceEquals(firstEditor, replacementEditor)
+ && ReferenceEquals(replacedEditor, firstEditor)
+ && factoryCallCount == 2,
+ "Replacing a column object under the same ID should discard its old editor instead of keeping stale event handlers.");
+
+ var currentColumns = new Dictionary(StringComparer.Ordinal)
+ {
+ ["stable-column"] = replacementColumn
+ };
+ tests.Check(
+ cache.RemoveColumnsExcept(firstWorkspace, currentColumns).Count == 0,
+ "The editor cache should retain entries still owned by the workspace's current columns.");
+
+ var removedEditors = cache.RemoveColumnsExcept(
+ firstWorkspace,
+ new Dictionary(StringComparer.Ordinal));
+ tests.Check(
+ removedEditors.Count == 1 && ReferenceEquals(removedEditors[0], replacementEditor),
+ "Removing a column should evict and return its editor for visual-tree cleanup.");
+
+ var recreatedEditor = cache.GetOrCreate(
+ firstWorkspace,
+ "stable-column",
+ replacementColumn,
+ () => CreateEditor(replacementColumn),
+ out _);
+ var secondVm = new MainViewModel();
+ var secondWorkspace = new WorkspaceSession("Second", secondVm);
+ var secondColumn = secondVm.Columns[0];
+ var secondEditor = cache.GetOrCreate(
+ secondWorkspace,
+ secondColumn.Id,
+ secondColumn,
+ () => CreateEditor(secondColumn),
+ out _);
+
+ var removedWorkspaceEditors = cache.RemoveWorkspacesExcept(new HashSet { secondWorkspace });
+ tests.Check(
+ removedWorkspaceEditors.Count == 1
+ && ReferenceEquals(removedWorkspaceEditors[0], recreatedEditor)
+ && ReferenceEquals(
+ cache.GetOrCreate(
+ secondWorkspace,
+ secondColumn.Id,
+ secondColumn,
+ () => CreateEditor(secondColumn),
+ out _),
+ secondEditor),
+ "Closing a workspace should evict only that workspace's cached editors.");
+ }
+
+ private static void VerifyColumnEditorStateReuse(SmokeTestContext tests)
+ {
+ var cache = new WorkspaceColumnEditorCache();
+ var vm = new MainViewModel();
+ var workspace = new WorkspaceSession("State", vm);
+ var column = vm.Columns[0];
+ column.WordWrap = false;
+
+ var editor = cache.GetOrCreate(
+ workspace,
+ column.Id,
+ column,
+ () => new ColumnEditorControl { DataContext = column },
+ out _);
+ var hostGrid = new Grid();
+ hostGrid.Children.Add(editor);
+ var host = new Window
+ {
+ Width = 360,
+ Height = 220,
+ Content = hostGrid,
+ DataContext = new PaperHostContext(vm),
+ ShowInTaskbar = false,
+ WindowStyle = WindowStyle.None
+ };
+
+ host.Show();
+ host.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ var textBox = editor.FindName("Editor") as TextBox;
+ textBox?.ApplyTemplate();
+ host.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ var scrollViewer = textBox is null ? null : FindDescendant(textBox);
+ var subscriptionField = typeof(ColumnEditorControl).GetField(
+ "_isObservedVmSubscribed",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ var checklistLayoutVersionField = typeof(ColumnEditorControl).GetField(
+ "_checklistLayoutVersion",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+
+ tests.Check(
+ textBox is not null && scrollViewer is not null,
+ "The editor reuse check should resolve the real text and scroll controls.");
+ tests.Check(
+ subscriptionField?.GetValue(editor) is true,
+ "A loaded column editor should observe its view model exactly once.");
+
+ if (textBox is not null && scrollViewer is not null)
+ {
+ var longLine = new string('x', 600);
+ textBox.Text = string.Join(
+ Environment.NewLine,
+ Enumerable.Range(1, 120).Select(index => $"{index:D3} {longLine}"));
+ textBox.Select(8, 0);
+ textBox.SelectedText = "edited ";
+ var hadUndoState = textBox.CanUndo;
+ textBox.Select(24, 11);
+ scrollViewer.ScrollToHorizontalOffset(220);
+ scrollViewer.ScrollToVerticalOffset(480);
+ host.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+
+ var selectionStart = textBox.SelectionStart;
+ var selectionLength = textBox.SelectionLength;
+ var caretIndex = textBox.CaretIndex;
+ var horizontalOffset = scrollViewer.HorizontalOffset;
+ var verticalOffset = scrollViewer.VerticalOffset;
+ tests.Check(
+ hadUndoState && horizontalOffset > 0 && verticalOffset > 0,
+ "The editor state check should establish undo history and both scroll offsets before reuse.");
+
+ hostGrid.Children.Remove(editor);
+ host.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ tests.Check(
+ subscriptionField?.GetValue(editor) is false,
+ "An unloaded cached editor should detach its view-model observation.");
+
+ var reusedEditor = cache.GetOrCreate(
+ workspace,
+ column.Id,
+ column,
+ () => new ColumnEditorControl { DataContext = column },
+ out _);
+ hostGrid.Children.Add(reusedEditor);
+ host.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+
+ tests.Check(
+ ReferenceEquals(reusedEditor, editor)
+ && textBox.CanUndo
+ && textBox.SelectionStart == selectionStart
+ && textBox.SelectionLength == selectionLength
+ && textBox.CaretIndex == caretIndex,
+ "Reusing a column editor should preserve its undo stack, caret, and text selection.");
+ tests.Check(
+ Math.Abs(scrollViewer.HorizontalOffset - horizontalOffset) < 0.5
+ && Math.Abs(scrollViewer.VerticalOffset - verticalOffset) < 0.5,
+ "Reusing a column editor should restore its horizontal and vertical scroll positions.");
+ tests.Check(
+ subscriptionField?.GetValue(editor) is true,
+ "Reloading a cached editor should reattach its view-model observation.");
+
+ if (checklistLayoutVersionField?.GetValue(editor) is int loadedVersion)
+ {
+ column.EditorFontSize += 1;
+ tests.Check(
+ checklistLayoutVersionField.GetValue(editor) is int changedVersion && changedVersion > loadedVersion,
+ "A reloaded editor should respond to relevant view-model changes.");
+
+ hostGrid.Children.Remove(editor);
+ host.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ var unloadedVersion = (int)checklistLayoutVersionField.GetValue(editor)!;
+ column.EditorFontSize += 1;
+ tests.Check(
+ checklistLayoutVersionField.GetValue(editor) is int unchangedVersion && unchangedVersion == unloadedVersion,
+ "An unloaded cached editor should not retain a duplicate view-model subscription.");
+
+ hostGrid.Children.Add(editor);
+ host.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ var reloadedVersion = (int)checklistLayoutVersionField.GetValue(editor)!;
+ column.EditorFontSize += 1;
+ tests.Check(
+ checklistLayoutVersionField.GetValue(editor) is int finalVersion && finalVersion > reloadedVersion,
+ "A cached editor should resume view-model updates after every reload.");
+ }
+ }
+
+ host.Close();
+ }
+
+ private static T? FindDescendant(DependencyObject parent) where T : DependencyObject
+ {
+ var childCount = VisualTreeHelper.GetChildrenCount(parent);
+ for (var index = 0; index < childCount; index++)
+ {
+ var child = VisualTreeHelper.GetChild(parent, index);
+ if (child is T match)
+ return match;
+
+ var nested = FindDescendant(child);
+ if (nested is not null)
+ return nested;
+ }
+
+ return null;
+ }
+
+ private static double SelectionContrast(Color background, Color foreground)
+ {
+ static double Luminance(Color color)
+ {
+ static double Linearize(byte component)
+ {
+ var channel = component / 255d;
+ return channel <= 0.04045
+ ? channel / 12.92
+ : Math.Pow((channel + 0.055) / 1.055, 2.4);
+ }
+
+ return (0.2126 * Linearize(color.R))
+ + (0.7152 * Linearize(color.G))
+ + (0.0722 * Linearize(color.B));
+ }
+
+ var backgroundLuminance = Luminance(background);
+ var foregroundLuminance = Luminance(foreground);
+ return (Math.Max(backgroundLuminance, foregroundLuminance) + 0.05)
+ / (Math.Min(backgroundLuminance, foregroundLuminance) + 0.05);
+ }
+
+ private static Color Blend(Color background, Color foreground, double foregroundOpacity)
+ {
+ var opacity = Math.Clamp(foregroundOpacity * foreground.A / 255d, 0, 1);
+ byte BlendChannel(byte backgroundChannel, byte foregroundChannel) =>
+ (byte)Math.Round(backgroundChannel + ((foregroundChannel - backgroundChannel) * opacity));
+
+ return Color.FromRgb(
+ BlendChannel(background.R, foreground.R),
+ BlendChannel(background.G, foreground.G),
+ BlendChannel(background.B, foreground.B));
+ }
+}
+
+internal sealed record PaperHostContext(MainViewModel ActiveVm);
diff --git a/tests/ColumnPadStudio.SmokeTests/WorkflowSmokeTests.cs b/tests/ColumnPadStudio.SmokeTests/WorkflowSmokeTests.cs
new file mode 100644
index 0000000..7a7381a
--- /dev/null
+++ b/tests/ColumnPadStudio.SmokeTests/WorkflowSmokeTests.cs
@@ -0,0 +1,288 @@
+using ColumnPadStudio.Services;
+using ColumnPadStudio.ViewModels;
+using ColumnPadStudio.Workflows;
+using System.Collections.ObjectModel;
+using System.IO;
+
+namespace ColumnPadStudio.SmokeTests;
+
+internal static class WorkflowSmokeTests
+{
+ public static WorkflowDefinition Run(SmokeTestContext tests, string layoutJson)
+ {
+ var workflowTemp = Path.Combine(Path.GetTempPath(), $"columnpad-workflows-{Guid.NewGuid():N}");
+ var workflowDefinition = new WorkflowDefinition { Name = "Colour test" };
+
+ try
+ {
+ var workflowService = new WorkflowService(workflowTemp);
+ var emptyWorkflowVm = new WorkflowBuilderViewModel(workflowService);
+ emptyWorkflowVm.Load();
+ tests.Check(emptyWorkflowVm.Workflows.Count == 1, "Workflow Builder should create one workflow when no saved workflows exist.");
+ tests.Check(WorkflowTemplateCatalog.Templates.Count >= 10, "Workflow starter catalog should provide multiple practical starters.");
+ var workflowTemplateIds = WorkflowTemplateCatalog.Templates.Select(template => template.Id).ToList();
+ tests.Check(workflowTemplateIds.Count == workflowTemplateIds.Distinct(StringComparer.OrdinalIgnoreCase).Count(), "Workflow starter catalog should not contain duplicate IDs.");
+ tests.Check(WorkflowTemplateCatalog.Templates.All(template => template.Nodes.Count > 0), "Workflow starter catalog should not contain empty starter diagrams.");
+ tests.Check(WorkflowTemplateCatalog.Templates.All(template => template.Connections.Count > 0), "Workflow starter catalog should wire starter nodes together.");
+ var essayStarter = WorkflowTemplateCatalog.Templates.FirstOrDefault(template => template.Id == "essay-plan");
+ tests.Check(essayStarter is not null, "Workflow starter catalog should include an essay planning starter.");
+ if (essayStarter is not null)
+ {
+ var essayWorkflow = essayStarter.CreateWorkflowInstance("Essay Plan Copy");
+ tests.Check(essayWorkflow.Name == "Essay Plan Copy", "Workflow starter instances should allow a custom workflow name.");
+ tests.Check(essayWorkflow.Nodes.Count >= 5, "Workflow starter instances should create a useful editable diagram.");
+ tests.Check(essayWorkflow.Links.Count > 0, "Workflow starter instances should create connections between starter nodes.");
+ var thesisNode = essayWorkflow.Nodes.FirstOrDefault(node => node.Title == "Define thesis");
+ tests.Check(!string.IsNullOrWhiteSpace(thesisNode?.Goal), "Workflow starter nodes should include a real goal, not just a box title.");
+ tests.Check(thesisNode?.ChecklistItems.Count >= 2, "Workflow starter nodes should include useful checklist data.");
+ }
+
+ var workflowBuilderVm = new WorkflowBuilderViewModel(workflowService);
+ workflowBuilderVm.AddWorkflow();
+ workflowBuilderVm.AddNode(WorkflowNodeKind.Decision);
+ tests.Check(workflowBuilderVm.SelectedNode?.Kind == WorkflowNodeKind.Decision, "Workflow builder palette should add the requested node kind.");
+ var firstDecision = workflowBuilderVm.SelectedNode!;
+ tests.Check(firstDecision.Title == "Decision", "The first added Decision should use the clean default title without a misleading workflow-wide number.");
+ tests.Check(!OverlapsAnyNode(firstDecision, workflowBuilderVm.SelectedWorkflow!.Nodes), "A newly added Decision should not overlap an existing workflow node.");
+
+ workflowBuilderVm.AddNode(WorkflowNodeKind.Decision);
+ var secondDecision = workflowBuilderVm.SelectedNode!;
+ tests.Check(secondDecision.Title == "Decision 2", "The second added Decision should use the next number for that node kind.");
+ tests.Check(!OverlapsAnyNode(secondDecision, workflowBuilderVm.SelectedWorkflow.Nodes), "A second added Decision should be placed without overlapping an existing workflow node.");
+
+ tests.Check(workflowBuilderVm.DuplicateSelectedNode(), "Workflow builder should duplicate the selected node.");
+ var duplicatedDecision = workflowBuilderVm.SelectedNode!;
+ tests.Check(workflowBuilderVm.SelectedWorkflow.Nodes.Count(node => string.Equals(node.Title, duplicatedDecision.Title, StringComparison.OrdinalIgnoreCase)) == 1,
+ "A duplicated node should receive a unique title.");
+ tests.Check(!OverlapsAnyNode(duplicatedDecision, workflowBuilderVm.SelectedWorkflow.Nodes), "A duplicated node should be placed without overlapping an existing workflow node.");
+
+ workflowBuilderVm.SelectedWorkflow.Nodes[0].Height = 180;
+ tests.Check(workflowBuilderVm.AutoLayoutSelectedWorkflow(), "Workflow builder should tidy the selected workflow positions.");
+ tests.Check(!ContainsOverlappingNodes(workflowBuilderVm.SelectedWorkflow.Nodes), "Workflow position tidying should account for node heights and leave every node non-overlapping.");
+
+ var connectionCountBeforeDraft = workflowBuilderVm.SelectedWorkflow.Links.Count;
+ workflowBuilderVm.ConnectionFromNode = null;
+ workflowBuilderVm.ConnectionToNode = null;
+ workflowBuilderVm.ConnectionLabel = "Decision route";
+ tests.Check(!workflowBuilderVm.CanCreateLink, "A connection should require both a From node and a To node.");
+ tests.Check(!workflowBuilderVm.AddLink() && workflowBuilderVm.SelectedWorkflow.Links.Count == connectionCountBeforeDraft,
+ "Adding a connection should fail without explicit endpoints.");
+
+ workflowBuilderVm.ConnectionFromNode = firstDecision;
+ tests.Check(!workflowBuilderVm.CanCreateLink && !workflowBuilderVm.AddLink(), "A connection should not be created when only its From node is selected.");
+ workflowBuilderVm.ConnectionToNode = firstDecision;
+ tests.Check(!workflowBuilderVm.CanCreateLink && !workflowBuilderVm.AddLink(), "A connection should require distinct From and To nodes.");
+
+ workflowBuilderVm.ConnectionToNode = secondDecision;
+ tests.Check(workflowBuilderVm.CanCreateLink, "A connection should become available after distinct From and To nodes are selected.");
+ tests.Check(workflowBuilderVm.AddLink(), "Workflow builder should create a connection with explicit distinct endpoints.");
+ var explicitLink = workflowBuilderVm.SelectedWorkflow.Links.LastOrDefault();
+ tests.Check(explicitLink is not null &&
+ explicitLink.FromNodeId == firstDecision.Id &&
+ explicitLink.ToNodeId == secondDecision.Id &&
+ explicitLink.Label == "Decision route",
+ "A created connection should preserve the exact selected endpoints and label.");
+ var connectionCountAfterExplicitLink = workflowBuilderVm.SelectedWorkflow.Links.Count;
+
+ workflowBuilderVm.ConnectionFromNode = firstDecision;
+ workflowBuilderVm.ConnectionToNode = secondDecision;
+ workflowBuilderVm.ConnectionLabel = "Duplicate route";
+ tests.Check(!workflowBuilderVm.CanCreateLink, "An existing pair of connection endpoints should not be offered again.");
+ tests.Check(!workflowBuilderVm.AddLink() && workflowBuilderVm.SelectedWorkflow.Links.Count == connectionCountAfterExplicitLink,
+ "Workflow builder should prevent duplicate connections between the same endpoints.");
+
+ var firstAddedNodeId = duplicatedDecision.Id;
+ workflowBuilderVm.SelectedNode = duplicatedDecision;
+ tests.Check(workflowBuilderVm.RemoveSelectedNode(), "Workflow builder should remove the selected node during ID regression setup.");
+ workflowBuilderVm.AddNode(WorkflowNodeKind.Decision);
+ tests.Check(workflowBuilderVm.SelectedNode!.Id != firstAddedNodeId, "Deleting and adding a workflow node should never reuse an earlier node ID.");
+ tests.Check(workflowBuilderVm.SelectedWorkflow!.Nodes.Select(node => node.Id).Distinct(StringComparer.Ordinal).Count() == workflowBuilderVm.SelectedWorkflow.Nodes.Count, "Workflow builder node IDs should remain unique after delete and add operations.");
+ workflowBuilderVm.SelectedNode!.X = 1260;
+ workflowBuilderVm.SelectedNode.Width = 220;
+ tests.Check(workflowBuilderVm.DiagramCanvasWidth >= 1576, "Workflow builder canvas should expand to include far-right nodes.");
+ workflowBuilderVm.SelectedNode.Y = 780;
+ workflowBuilderVm.SelectedNode.Height = 120;
+ tests.Check(workflowBuilderVm.DiagramCanvasHeight >= 996, "Workflow builder canvas should expand to include lower nodes.");
+
+ workflowDefinition.Id = " workflow id with spaces ";
+ workflowDefinition.Category = "Test plans";
+ workflowDefinition.Description = "Round-trip workflow description";
+ tests.Check(workflowDefinition.Id == "workflow id with spaces", "Workflow IDs should trim outer whitespace without applying display-label cleanup.");
+ workflowDefinition.Nodes.Add(new WorkflowDiagramNode
+ {
+ Id = " start ",
+ Kind = WorkflowNodeKind.Start,
+ Title = "Start",
+ Description = "Round-trip node description",
+ Color = WorkflowNodeColor.Rose,
+ Goal = "Round-trip goal",
+ Instructions = "Round-trip instructions",
+ ExpectedOutput = "Round-trip output",
+ X = 123.4,
+ Y = 234.5,
+ Width = 210.6,
+ Height = 98.7,
+ ChecklistItems = new ObservableCollection
+ {
+ new() { Text = "First check" },
+ new() { Text = "Done check", IsDone = true }
+ }
+ });
+ workflowDefinition.Nodes.Add(new WorkflowDiagramNode { Id = "end", Kind = WorkflowNodeKind.End, Title = "End", Color = WorkflowNodeColor.Green });
+ tests.Check(workflowDefinition.Nodes[0].Id == "start", "Workflow node IDs should use identity cleanup, not display-label cleanup.");
+ workflowDefinition.Links.Add(new WorkflowDiagramLink { Id = "primary-link", FromNodeId = "start", ToNodeId = "end", Label = "Continue" });
+ workflowService.Save(workflowDefinition);
+ tests.Check(!string.IsNullOrWhiteSpace(workflowDefinition.FilePath), "Workflow save should assign a file path.");
+ var savedWorkflowJson = File.ReadAllText(workflowDefinition.FilePath!);
+ tests.Check(savedWorkflowJson.Contains("\n \"SchemaVersion\":", StringComparison.Ordinal), "Saved workflow JSON should remain indented and readable in a text editor.");
+ tests.Check(workflowService.TryLoad(workflowDefinition.FilePath!, out var loadedWorkflow), "Workflow service should reload saved workflow JSON.");
+ tests.Check(loadedWorkflow.SchemaVersion == WorkflowDefinition.CurrentSchemaVersion, "Workflow service should normalize saved workflows to the current schema.");
+ tests.Check(loadedWorkflow.Id == "workflow id with spaces" && loadedWorkflow.Name == "Colour test", "Workflow identity and name should persist through JSON save/load.");
+ tests.Check(loadedWorkflow.Category == "Test plans" && loadedWorkflow.Description == "Round-trip workflow description", "Workflow category and description should persist through JSON save/load.");
+ tests.Check(loadedWorkflow.Nodes.Count == 2, "Workflow node count should persist through JSON save/load.");
+ var loadedStartNode = loadedWorkflow.Nodes.FirstOrDefault(node => node.Id == "start");
+ tests.Check(loadedStartNode is not null, "Workflow node IDs should persist through JSON save/load.");
+ if (loadedStartNode is not null)
+ {
+ tests.Check(loadedStartNode.Kind == WorkflowNodeKind.Start && loadedStartNode.Title == "Start", "Workflow node kind and title should persist through JSON save/load.");
+ tests.Check(loadedStartNode.Description == "Round-trip node description", "Workflow node description should persist through JSON save/load.");
+ tests.Check(loadedStartNode.Color == WorkflowNodeColor.Rose, "Workflow node colour should persist through JSON save/load.");
+ tests.Check(loadedStartNode.Goal == "Round-trip goal", "Workflow node goal should persist through JSON save/load.");
+ tests.Check(loadedStartNode.Instructions == "Round-trip instructions", "Workflow node instructions should persist through JSON save/load.");
+ tests.Check(loadedStartNode.ExpectedOutput == "Round-trip output", "Workflow node expected output should persist through JSON save/load.");
+ tests.Check(loadedStartNode.X == 123.4 && loadedStartNode.Y == 234.5, "Workflow node position should persist through JSON save/load.");
+ tests.Check(loadedStartNode.Width == 210.6 && loadedStartNode.Height == 98.7, "Workflow node size should persist through JSON save/load.");
+ tests.Check(loadedStartNode.ChecklistItems.Count == 2 &&
+ loadedStartNode.ChecklistItems[0].Text == "First check" &&
+ !loadedStartNode.ChecklistItems[0].IsDone &&
+ loadedStartNode.ChecklistItems[1].Text == "Done check" &&
+ loadedStartNode.ChecklistItems[1].IsDone,
+ "Workflow node checklist text and completion state should persist through JSON save/load.");
+ }
+
+ var loadedLink = loadedWorkflow.Links.FirstOrDefault(link => link.Id == "primary-link");
+ tests.Check(loadedLink is not null &&
+ loadedLink.FromNodeId == "start" &&
+ loadedLink.ToNodeId == "end" &&
+ loadedLink.Label == "Continue",
+ "Workflow link identity, endpoints, and label should persist through JSON save/load.");
+ var readableWorkflowText = workflowService.BuildTextExport(workflowDefinition);
+ tests.Check(readableWorkflowText.StartsWith(WorkflowService.TextExportMarker, StringComparison.Ordinal), "Workflow text export should include a clear ColumnPad marker.");
+ tests.Check(readableWorkflowText.Contains("Readable copy only; import the .workflow.json file to continue editing.", StringComparison.Ordinal), "Workflow text export should explain that the readable copy is not reloadable.");
+ tests.Check(readableWorkflowText.Contains("Workflow: Colour test"), "Workflow text export should include the workflow name.");
+ tests.Check(readableWorkflowText.Contains("1. [Start] Start"), "Workflow text export should list readable node steps.");
+ tests.Check(readableWorkflowText.Contains("Round-trip goal"), "Workflow text export should include node goals.");
+ tests.Check(readableWorkflowText.Contains("- [x] Done check"), "Workflow text export should include checklist completion state.");
+ tests.Check(readableWorkflowText.Contains("Next:\r\n - 2. End (Continue)", StringComparison.Ordinal) ||
+ readableWorkflowText.Contains("Next:\n - 2. End (Continue)", StringComparison.Ordinal),
+ "Workflow text export should show each connection once as the next step.");
+ tests.Check(!readableWorkflowText.Contains("Connections", StringComparison.Ordinal), "Workflow text export should not repeat connections in a second summary.");
+ var readableWorkflowTextPath = Path.Combine(workflowTemp, "colour-test.workflow.txt");
+ workflowService.ExportTextToPath(workflowDefinition, readableWorkflowTextPath);
+ tests.Check(File.Exists(readableWorkflowTextPath), "Workflow text export should write a text file.");
+ var existingWorkflowVm = new WorkflowBuilderViewModel(workflowService);
+ existingWorkflowVm.Load();
+ var workflowCountBeforeAdd = existingWorkflowVm.Workflows.Count;
+ existingWorkflowVm.AddWorkflow();
+ tests.Check(existingWorkflowVm.Workflows.Count == workflowCountBeforeAdd + 1, "Workflow Builder Add Workflow should add one workflow.");
+
+ tests.Check(!WorkflowService.IsWorkflowDefinitionJson("{}"), "Workflow detection should reject unrelated empty JSON objects.");
+ tests.Check(!WorkflowService.IsWorkflowDefinitionJson(layoutJson), "Workflow detection should reject ColumnPad layout JSON.");
+ var camelCaseWorkflowPath = Path.Combine(workflowTemp, "camel-case.workflow.json");
+ File.WriteAllText(camelCaseWorkflowPath, """
+ {
+ "fileType": "ColumnPadWorkflow",
+ "schemaVersion": 3,
+ "id": "camel-case",
+ "name": "Camel Case Workflow",
+ "nodes": [
+ { "id": "start", "kind": "Start", "title": "Start" }
+ ],
+ "links": []
+ }
+ """);
+ tests.Check(workflowService.TryLoad(camelCaseWorkflowPath, out var camelCaseWorkflow), "Workflow import should accept case-insensitive property names and readable enum names.");
+ tests.Check(camelCaseWorkflow.Nodes.Count == 1 && camelCaseWorkflow.Nodes[0].Kind == WorkflowNodeKind.Start, "Case-insensitive workflow import should preserve node data.");
+
+ var legacyWorkflowPath = Path.Combine(workflowTemp, "legacy.workflow.json");
+ File.WriteAllText(legacyWorkflowPath, """
+ {
+ "SchemaVersion": 1,
+ "Id": "legacy-flow",
+ "Name": "Legacy Workflow",
+ "Category": "Compatibility",
+ "Description": "An older executable workflow.",
+ "Trigger": "Manual",
+ "Steps": [
+ { "Kind": "SetColumnCount", "Argument": "4", "Notes": "Prepare four writing areas." },
+ { "Kind": "SetTheme", "Argument": "Dark Mode", "Notes": "Use the dark palette." }
+ ]
+ }
+ """);
+ tests.Check(WorkflowService.IsWorkflowDefinitionJson(File.ReadAllText(legacyWorkflowPath)), "Workflow detection should recognize the published version-1 Steps format.");
+ tests.Check(workflowService.TryLoad(legacyWorkflowPath, out var migratedLegacyWorkflow), "Workflow service should migrate version-1 Steps workflows.");
+ tests.Check(migratedLegacyWorkflow.SchemaVersion == WorkflowDefinition.CurrentSchemaVersion, "Migrated workflows should use the current schema.");
+ tests.Check(migratedLegacyWorkflow.Nodes.Count == 4 && migratedLegacyWorkflow.Links.Count == 3, "Legacy steps should become one connected Start-to-End diagram.");
+ tests.Check(migratedLegacyWorkflow.Nodes[1].Title == "Set column count" && migratedLegacyWorkflow.Nodes[1].Instructions.Contains('4'), "Legacy step kind and argument data should remain readable after migration.");
+ tests.Check(migratedLegacyWorkflow.Nodes[2].Description == "Use the dark palette.", "Legacy step notes should be preserved during migration.");
+
+ var futureWorkflowJson = $$"""
+ {
+ "FileType": "ColumnPadWorkflow",
+ "SchemaVersion": {{WorkflowDefinition.CurrentSchemaVersion + 1}},
+ "Nodes": [],
+ "Links": []
+ }
+ """;
+ tests.Check(!WorkflowService.IsWorkflowDefinitionJson(futureWorkflowJson), "Workflow detection should reject unsupported future schema versions.");
+
+ var invalidWorkflowPath = Path.Combine(workflowTemp, "invalid.workflow.json");
+ File.WriteAllText(invalidWorkflowPath, "{}");
+ _ = workflowService.LoadAll();
+ tests.Check(workflowService.LastLoadWarnings.Contains("invalid.workflow.json"), "Workflow library loading should report unreadable workflow filenames instead of silently skipping them.");
+
+ var dirtyWorkflowService = new WorkflowService(Path.Combine(workflowTemp, "dirty-state"));
+ var dirtyWorkflowVm = new WorkflowBuilderViewModel(dirtyWorkflowService);
+ dirtyWorkflowVm.Load();
+ tests.Check(!dirtyWorkflowVm.HasUnsavedChanges, "Opening an empty Workflow Builder should not treat its untouched blank draft as a user edit.");
+ dirtyWorkflowVm.SelectedWorkflow!.Name = "My Workflow";
+ tests.Check(dirtyWorkflowVm.HasUnsavedChanges, "Editing the blank workflow draft should mark it unsaved.");
+ dirtyWorkflowVm.SaveSelectedWorkflow();
+ tests.Check(!dirtyWorkflowVm.HasUnsavedChanges, "Saving a workflow should establish a clean state.");
+ dirtyWorkflowVm.SelectedWorkflow!.Description = "Changed after save";
+ tests.Check(dirtyWorkflowVm.HasUnsavedChanges, "Editing workflow details should mark the Workflow Builder dirty.");
+ tests.Check(dirtyWorkflowVm.SaveAllChangedWorkflows() == 1, "Save-all should save each changed workflow once.");
+ tests.Check(!dirtyWorkflowVm.HasUnsavedChanges, "Save-all should clear the Workflow Builder dirty state.");
+ }
+ finally
+ {
+ if (Directory.Exists(workflowTemp))
+ Directory.Delete(workflowTemp, recursive: true);
+ }
+
+ return workflowDefinition;
+ }
+
+ private static bool OverlapsAnyNode(WorkflowDiagramNode node, IEnumerable nodes)
+ => nodes.Any(other => !ReferenceEquals(node, other) && NodesOverlap(node, other));
+
+ private static bool ContainsOverlappingNodes(IReadOnlyList nodes)
+ {
+ for (var leftIndex = 0; leftIndex < nodes.Count; leftIndex++)
+ {
+ for (var rightIndex = leftIndex + 1; rightIndex < nodes.Count; rightIndex++)
+ {
+ if (NodesOverlap(nodes[leftIndex], nodes[rightIndex]))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool NodesOverlap(WorkflowDiagramNode left, WorkflowDiagramNode right)
+ => left.X < right.X + right.Width &&
+ left.X + left.Width > right.X &&
+ left.Y < right.Y + right.Height &&
+ left.Y + left.Height > right.Y;
+}